From d1a9ce3c01bab6f3c11166f22f3b9b5ff2b2c0d4 Mon Sep 17 00:00:00 2001 From: Ed Sun Date: Wed, 22 Jul 2026 11:45:14 -0700 Subject: [PATCH 1/7] feat: partition the routing index by LoRA adapter so hints can't alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The content fingerprint is derived from token IDs alone, so two requests with identical tokens under different LoRA adapters produce the same prefix_hash and collided on one index entry — a LookupRoute for adapter A could be handed a replica holding only adapter B's KV. Runtime LoRA load/unload makes that ordinary rather than theoretical. Partition the index, not the hash. The key becomes (tenant, model, hash_scheme, adapter, prefix_hash); pkg/fingerprint and its golden vectors are untouched. - proto: adapter_id on LookupRouteRequest (11), LookupRouteResponse (5), PrefixEntry (5), CacheStateUpdate (8), CacheEvent (7). Additive only. - subscriber: decode BlockStored.lora_id (it was already on the wire and ignored), resolve it to a stable identity via --lora-adapter-names, and stamp it per entry; PREFIX_EVICTED names its partition so one adapter's eviction no longer drops another's identical hash. - server: thread adapter through ingest and lookup; echo the partition on hint-bearing responses. Empty/unset adapter is the default partition and is exactly the prior behavior, so non-LoRA deployments are unaffected. The (tenant, model, hash_scheme) scope deliberately stays adapter-free: it answers a replica membership question, so UNKNOWN_HASH_SCHEME keeps its meaning. --- cmd/kvevent-subscriber/main.go | 16 +- docs/design/grpc-contract.md | 30 +- docs/design/kvevent-subscriber-wiring.md | 31 + .../scripts/default_install_smoke.sh | 82 ++- pkg/adapters/engine/config.go | 80 ++- pkg/adapters/engine/events.go | 58 +- pkg/adapters/engine/forwarder.go | 10 +- pkg/adapters/engine/lora_adapter_test.go | 240 +++++++ pkg/adapters/engine/mapper.go | 15 +- pkg/adapters/engine/mapper_test.go | 14 +- pkg/adapters/engine/positional.go | 57 +- pkg/adapters/engine/positional_test.go | 19 +- pkg/index/adapter_partition_test.go | 269 ++++++++ pkg/index/index.go | 112 +++- pkg/index/lfu_eviction_test.go | 4 +- pkg/server/adapter_partition_test.go | 125 ++++ pkg/server/inferencecache_service.go | 29 +- .../lmcache_offload_integration_test.go | 17 +- .../v1alpha1/inferencecache.pb.go | 597 ++++++++++-------- .../v1alpha1/inferencecache.proto | 46 ++ 20 files changed, 1537 insertions(+), 314 deletions(-) create mode 100644 pkg/adapters/engine/lora_adapter_test.go create mode 100644 pkg/index/adapter_partition_test.go create mode 100644 pkg/server/adapter_partition_test.go diff --git a/cmd/kvevent-subscriber/main.go b/cmd/kvevent-subscriber/main.go index 19c6661b..890de276 100644 --- a/cmd/kvevent-subscriber/main.go +++ b/cmd/kvevent-subscriber/main.go @@ -50,16 +50,24 @@ func main() { cacheTier = flag.String("cache-tier", "auto", `which vLLM cache-usage gauge to read: "auto" (kv→gpu→cpu fallback) | "kv" | "gpu" | "cpu"`) engineModel = flag.String("engine-model-name", "", `value of the engine's `+"`model_name`"+` Prometheus label to filter /metrics by (e.g. "Qwen/Qwen2.5-0.5B-Instruct"). Distinct from --model-id (the cache-plane index key). Empty = no label filter (aggregates every series — fine when the engine serves one model).`) ignoreBlockRemoved = flag.Bool("ignore-block-removed", false, `drop BlockRemoved events instead of forwarding them as PREFIX_EVICTED. Set for engines paired with an L2 cache tier (e.g. LMCache); default off for single-tier deployments. See docs/design/kvevent-subscriber-wiring.md "L2 cache tier semantics".`) + adapterNames = flag.String("lora-adapter-names", "", `comma-separated `+"`id=name`"+` map from the engine's internal LoRA id (BlockStored.lora_id, assigned in --lora-modules load order) to the stable adapter identity used as the index partition — the same string the gateway sends as LookupRouteRequest.adapter_id (e.g. "1=sql-lora,2=chat-lora"). Unmapped ids fall back to "lora:". Leave empty for single-adapter or LoRA-free deployments.`) ) flag.Parse() logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) + names, err := engine.ParseAdapterNames(*adapterNames) + if err != nil { + logger.Error("invalid --lora-adapter-names", "value", *adapterNames, "err", err) + os.Exit(2) + } + cfg := engine.Config{ - ReplicaID: *replica, - ModelID: *model, - TenantID: *tenant, - HashScheme: *scheme, + ReplicaID: *replica, + ModelID: *model, + TenantID: *tenant, + HashScheme: *scheme, + AdapterNames: names, } if err := cfg.Validate(); err != nil { logger.Error("invalid config", "err", err) diff --git a/docs/design/grpc-contract.md b/docs/design/grpc-contract.md index 4dcea824..6e7b30e1 100644 --- a/docs/design/grpc-contract.md +++ b/docs/design/grpc-contract.md @@ -44,8 +44,8 @@ service InferenceCache { - **RenderTemplateRequest** `{ string template_ref; map variables; string tenant_id; }` - **RenderTemplateResponse** `{ bytes rendered_prompt; bytes stable_prefix_hash; bytes tenant_namespace; string reason_code; string template_revision; }` reason ∈ `OK | TEMPLATE_NOT_FOUND | RENDER_ERROR` -- **LookupRouteRequest** `{ string model_id = 1; string tenant_id = 2; bytes prefix_hash = 3; int32 prefix_token_count = 4; string hash_scheme = 5; SLO slo = 6; repeated bytes block_hashes = 7; repeated int32 block_token_counts = 8; repeated uint32 token_ids = 9; string prompt_text = 10; }` -- **LookupRouteResponse** `{ repeated ReplicaScore replica_scores; string reason_code; int64 lookup_latency_us; repeated uint32 token_ids = 4; }` +- **LookupRouteRequest** `{ string model_id = 1; string tenant_id = 2; bytes prefix_hash = 3; int32 prefix_token_count = 4; string hash_scheme = 5; SLO slo = 6; repeated bytes block_hashes = 7; repeated int32 block_token_counts = 8; repeated uint32 token_ids = 9; string prompt_text = 10; string adapter_id = 11; }` +- **LookupRouteResponse** `{ repeated ReplicaScore replica_scores; string reason_code; int64 lookup_latency_us; repeated uint32 token_ids = 4; string adapter_id = 5; }` reason ∈ `PREFIX_MATCH | TENANT_HOT | AFFINITY_HINT | NO_HINT | POLICY_REQUIRES_CHAIN | TIMEOUT | UNKNOWN_TENANT | UNKNOWN_MODEL | UNKNOWN_HASH_SCHEME` - **LookupPDRouteRequest** `{ string model_id; string tenant_id; bytes prefix_hash; int32 prefix_token_count; string pd_topology_ref; }` - **LookupPDRouteResponse** `{ string prefill_replica_id; string decode_replica_id; string transport_hint; string reason_code; }` @@ -53,13 +53,15 @@ service InferenceCache { - **GetCacheStateRequest** `{ string model_id; string tenant_id; }` / **GetCacheStateResponse** `{ repeated ReplicaStats replicas; CacheSummary summary; }` - **ReplicaScore** `{ string replica_id = 1; float score = 2; int32 matched_tokens = 3; float estimated_cache_hit_prob = 4; CacheTier tier = 5; }` `tier` is the **CacheTier** from which the replica can serve the **entire** matched prefix — for a block-chain match, the least-local (coldest) tier among the matched blocks: one block only present in a colder tier constrains the whole run, so a warmer claim would overstate the hint. Per-block holds are reported most-local at ingest; only the across-run summary takes the constraining view. `CacheTier ∈ CACHE_TIER_UNSPECIFIED (0) | CACHE_TIER_T1 (1) | CACHE_TIER_T2 (2) | CACHE_TIER_T3 (3)`, ordered most-local (T1 — engine KV cache) to coldest (T3 — remote / disaggregated); T2 is external offload (e.g. LMCache). `UNSPECIFIED` on a hint with no per-prefix evidence (`TENANT_HOT` / `AFFINITY_HINT`), so old clients that ignore the field are unaffected (additive, v1alpha1-compatible). Tier *detection* (classifying a hold as T2/T3 from engine offload signals) is out of scope for this update — the ingest path currently tags every stored prefix `T1`; the field carries the value, it is not yet a routing input (the ranker does not read `tier`). -- **CacheStateUpdate** `{ string replica_id; string model_id; string tenant_id; string hash_scheme; int64 timestamp_us; repeated PrefixEntry prefixes; ReplicaStats stats; }` -- **PrefixEntry** `{ bytes prefix_hash = 1; int32 token_count = 2; repeated bytes block_hashes = 3; repeated int32 block_token_counts = 4; }` — **metadata only** +- **CacheStateUpdate** `{ string replica_id; string model_id; string tenant_id; string hash_scheme; int64 timestamp_us; repeated PrefixEntry prefixes; ReplicaStats stats; string adapter_id = 8; }` +- **PrefixEntry** `{ bytes prefix_hash = 1; int32 token_count = 2; repeated bytes block_hashes = 3; repeated int32 block_token_counts = 4; string adapter_id = 5; }` — **metadata only** + `adapter_id` is the **index partition** the entry belongs to (see "Update — adapter (LoRA) index partition" below). Per-entry rather than per-update because one replica can hold KV for several adapters at once; empty falls back to `CacheStateUpdate.adapter_id`, and an empty result is the default partition (pre-adapter behavior). Additive, v1alpha1-compatible. - **ReplicaStats** `{ string replica_id = 1; int64 cache_memory_bytes = 2; float hit_rate = 3; float pressure = 4; string client_version = 5; int64 t2_hit_tokens = 6; int64 t2_query_tokens = 7; }` `client_version` is an opaque version string identifying the client-side cache library the reporting replica is linked against (e.g. an LMCache client release). It carries no semver semantics on the wire — producers populate it, the server accepts it, no semver parsing or ordering is performed at this layer. Empty / unset is allowed and means "unknown"; older producers that don't fill the field MUST keep being accepted (additive, v1alpha1-compatible). The field reserves wire space for the producer half of an end-to-end client/server version-skew detection surface; the consumer half (server-side storage, per-CacheBackend exposure, the operator-visible status condition that closes the silent client/server mismatch class of bug) lands in a follow-up change and is intentionally out of scope for this contract update. `t2_hit_tokens` / `t2_query_tokens` are cumulative token counters for the **tier-2 (external offload, e.g. LMCache) cache**, sourced from the engine's `vllm:external_prefix_cache_{hits,queries}_total`. The server derives a presence-aware tier-2 hit-rate per CacheBackend (`status.indexParticipation.t2HitRate`): nil until the tier is exercised (`t2_query_tokens > 0`), then the query-weighted `hits/queries` ratio — so a value of `0` means tier-2 is configured but serving no reloads (a silently-degraded offload tier), distinct from "not yet used". Both default to 0 for producers that don't populate them (additive, v1alpha1-compatible). -- **CacheEvent** `{ Type type; string replica_id; string model_id; string tenant_id; bytes prefix_hash; int64 timestamp_us; }` +- **CacheEvent** `{ Type type; string replica_id; string model_id; string tenant_id; bytes prefix_hash; int64 timestamp_us; string adapter_id = 7; }` Type ∈ `PREFIX_ADDED | PREFIX_EVICTED | REPLICA_UPDATED | ALL_CLEARED` + `adapter_id` narrows a `PREFIX_EVICTED` to one adapter partition; empty removes the prefix across **every** partition (the conservative legacy behavior — removal is soft state). Ignored by `ALL_CLEARED` (a flush clears the replica across adapters) and `REPLICA_UPDATED` (liveness is adapter-independent). - **Metric** `{ string name; string type; map labels; double value; int64 timestamp_us; }` (Prometheus `inferencecache_*`, tech spec §4.3) - **StreamEventsRequest** `{ string model_id; string tenant_id; repeated CacheEvent.Type types; }` - **StreamMetricsRequest** `{ repeated string names; }` @@ -245,7 +247,7 @@ Construction (XXH3-64, matching SMG `event_tree.rs` so the two interoperate on o Golden vectors: `pkg/fingerprint/testdata/golden_vectors.json` is the canonical, language-neutral fixture pinning this construction (each hash as both decimal `uint64` and 8-byte big-endian base64, including the parent-chain case). Every implementation — Go (`pkg/fingerprint`), the benchmark gateway client (Python), SMG `event_tree.rs` (Rust) — asserts byte-for-byte parity against it; a unilateral change to the construction fails tests in all of them. -The fingerprint is positional (block `i`'s key identifies the whole prefix `0..i`), satisfying the parent-chained block-hash assumption above. Both ends must agree: the `kvevent-subscriber` computes it in-pod from the engine event's `token_ids` (the tokens never leave the pod — only the hash does); a gateway recomputes it from the prompt's tokens. Reference implementation: `pkg/fingerprint`. **Caveat:** the fingerprint is token-content-only, so prefixes that differ only by LoRA adapter (identical tokens) share a key unless partitioned by `model_id` — tracked as a follow-up. +The fingerprint is positional (block `i`'s key identifies the whole prefix `0..i`), satisfying the parent-chained block-hash assumption above. Both ends must agree: the `kvevent-subscriber` computes it in-pod from the engine event's `token_ids` (the tokens never leave the pod — only the hash does); a gateway recomputes it from the prompt's tokens. Reference implementation: `pkg/fingerprint`. The fingerprint is token-content-only, so prefixes that differ only by LoRA adapter (identical tokens) share a key — resolved by partitioning the **index**, not the hash; see "Update — adapter (LoRA) index partition" below. **Update — dual-input `LookupRoute` (server-side tokenization).** `LookupRouteRequest` adds `token_ids` (9) and `prompt_text` (10) so a gateway that carries **no tokenizer** can let the server compute the fingerprint. The server resolves exactly one input by precedence: @@ -254,3 +256,19 @@ The fingerprint is positional (block `i`'s key identifies the whole prefix `0..i 3. `prompt_text` — the server wraps the text as a **single `user` chat message**, applies `model_id`'s chat template **with the assistant generation prompt appended**, tokenizes (the server-owned tokenizer), then fingerprints as in (2). This single-turn wrapping is the only supported shape today; multi-turn message arrays and already-rendered / completion-style prompts are not (a structured-messages field is a future addition), and `token_ids` is the escape hatch for callers needing exact control. This path requires a server built with the tokenizer (build tag `smgcgo`) **and** an explicit, vetted `--tokenizer-models-dir`; without either it **fails open to `NO_HINT`** rather than erroring. Tokenizers are **loaded eagerly at startup** from that directory (one subdir per model) and served from memory — the lookup path itself performs **no per-request I/O or downloads** and a request `model_id` is never joined onto a filesystem path, so `LookupRoute` stays side-effect-free apart from metrics (the model_id is confined to the pre-loaded set; an unknown model fails open). Tokenization is still bounded by the tenant's `lookupTimeoutMs`, or a default safety timeout when none is set. The canonical `token_ids` the server produced are echoed in `LookupRouteResponse.token_ids` (4) **only on this path**, so the caller forwards exactly those tokens to the engine (e.g. OpenAI `/v1/completions` with a token-id `prompt`) — the engine then caches the same tokens the lookup was fingerprinted over, so routing key and engine cache key match **by construction**, with no tokenizer-parity dependence between gateway and engine. `model_id` / `tenant_id` / `hash_scheme` are still supplied by the caller on every path — they are engine/routing config, not tokenizer knowledge. The block size used to fingerprint token_ids / prompt_text MUST match the engine's KV block size and the kvevent-subscriber's; it defaults to 16 (vLLM's default) and is set with the server's `--engine-block-size` flag. All input paths funnel into the identical block-chain matching semantics (the longest-leading-run rule above), so the matched-tokens / routing-floor gates and fail-open behavior are unchanged once a chain is accepted. A `token_ids` / `prompt_text` input too short to fill one block yields an empty chain → fail-open `NO_HINT`; with `CachePolicy.spec.strategy.requireChain=true`, callers must send a valid wire `block_hashes` + `block_token_counts` chain, otherwise the server fails open with `POLICY_REQUIRES_CHAIN` before resolving dual-input fallbacks. `LookupRoute` stays side-effect-free apart from metrics (tokenization is transient: the server sees prompt text only to hash it, never stores it). + +**Update — adapter (LoRA) index partition.** The content fingerprint above is derived from **token IDs alone**, so two requests with identical tokens under *different* LoRA adapters produce the **same** `prefix_hash` / `block_hashes` and previously collided on one index entry: a `LookupRoute` for adapter A could be handed a replica that only holds adapter B's KV for those tokens. Runtime LoRA load/unload (adapters swapping under a live engine, no restart) makes that ordinary rather than theoretical, and vLLM's `BlockStored` event has always carried the `lora_id` needed to tell them apart. + +The fix partitions the **index**, not the hash: + +- `LookupRouteRequest` gains `adapter_id` (11) and `PrefixEntry` gains `adapter_id` (5), with `CacheStateUpdate.adapter_id` (8) as the per-update default for entries that set none. The server's index key becomes **`(tenant_id, model_id, hash_scheme, adapter_id, prefix_hash)`** — `adapter_id` joins the existing partition ahead of the content hash. +- **The fingerprint construction is unchanged.** Adapter identity is never mixed into the hash, so `pkg/fingerprint`, its golden vectors, and every cross-language implementation (Go / Python / Rust) stay byte-for-byte as they were. The hash still answers "what content is this?"; the partition answers "whose KV is it?". +- **Matching inside a partition is unchanged.** Exact-match and the block-chain longest-leading-run rule behave identically; the partition only bounds which entries are candidates. +- `LookupRouteResponse.adapter_id` (5) echoes the partition the index was consulted in, so a caller can confirm the round trip. It is empty on the fail-open envelopes returned *before* the index is consulted (`TIMEOUT`, tokenizer fail-open, `POLICY_REQUIRES_CHAIN`). +- `CacheEvent.adapter_id` (7) narrows a `PREFIX_EVICTED` to one partition. Without it, one adapter's GPU eviction would drop the identical hash under *every* adapter — discarding hints that are still valid. Empty keeps the original cross-partition sweep. + +**Producer / consumer must agree, exactly as they already do for `hash_scheme`.** A lookup that sets `adapter_id` will not match entries ingested without one, and vice versa. Empty/unset is the **default partition** and is exactly the pre-adapter behavior: a single-adapter or LoRA-free deployment ingests and looks up under `""` and is completely unaffected — no regression, no configuration required. + +**Adapter identity is a stable string, not the engine's integer.** vLLM's `lora_id` is an internal load-order integer, so the *same* integer can mean different adapters on two replicas whose `--lora-modules` order differs — and the index is shared across replicas. The `kvevent-subscriber` therefore maps it to a stable identity via `--lora-adapter-names` (`id=name`, e.g. `1=sql-lora,2=chat-lora`), the same string the gateway sends as `adapter_id`. An unmapped id falls back to `lora:`, which is exact within a replica and agrees across replicas that share an adapter load order (the homogeneous-Deployment case). + +**Diagnostics are deliberately adapter-blind.** The `(tenant, model, hash_scheme)` *scope* — which backs the distinguishing-power denominator, the `TENANT_HOT` serving check, the `AFFINITY_HINT` candidate set, and the `UNKNOWN_HASH_SCHEME` classifier — does **not** gain `adapter_id`. Those surfaces answer a replica-membership question and carry no per-adapter cache-content claim (`TENANT_HOT` / `AFFINITY_HINT` ship `matched_tokens=0` by contract), and adapters load and unload under a live engine, so adapter residency is a property of individual KV entries rather than of a replica's membership in an engine domain. Consequence: a lookup for an unseen adapter in a *known* engine domain is a novel-prefix miss (`NO_HINT` / `AFFINITY_HINT`), not `UNKNOWN_HASH_SCHEME` — that code keeps meaning "wrong `hash_scheme`". diff --git a/docs/design/kvevent-subscriber-wiring.md b/docs/design/kvevent-subscriber-wiring.md index 19d437c9..d5265106 100644 --- a/docs/design/kvevent-subscriber-wiring.md +++ b/docs/design/kvevent-subscriber-wiring.md @@ -148,6 +148,37 @@ the L2 tier is present only in one of them: Other adapters (e.g. plain vLLM, or future runtimes with no L2 tier) leave the flag off for the same reason as `EventsOnly`. +## LoRA adapter identity — `--lora-adapter-names` + +The routing key is a **content fingerprint over token IDs only**, so two prompts +with identical tokens under different LoRA adapters hash identically. The +subscriber therefore reads `lora_id` off each `BlockStored` event and stamps the +resolved adapter identity on every reported `PrefixEntry`, so the server can put +them in disjoint **index partitions** rather than one aliased entry (see +[`grpc-contract.md`](grpc-contract.md) "Update — adapter (LoRA) index +partition"). The adapter never enters the hash. + +`lora_id` is vLLM's **internal load-order integer**, not a name: the same integer +can mean different adapters on two replicas whose `--lora-modules` order differs, +and the index is shared across replicas. `--lora-adapter-names` maps it to the +stable identity the gateway sends as `LookupRouteRequest.adapter_id`: + +``` +--lora-adapter-names "1=sql-lora,2=chat-lora" +``` + +- **Unmapped id** → `lora:`. Exact within a replica, and consistent across + replicas that share an adapter load order (the homogeneous-Deployment case). + Supply the map when replicas can diverge — runtime load/unload, rolling + updates — and make the gateway send the matching `adapter_id`. +- **No LoRA at all** (the default): every event carries a nil `lora_id`, so + every entry and eviction lands in the default (`""`) partition — byte-for-byte + the pre-adapter behavior. Nothing to configure. + +The flag is not set by the CacheBackend-driven injection path today: the +controller has no view of the engine's `--lora-modules` ordering, so operators +running multi-adapter engines set it on the subscriber container explicitly. + ## What this unblocks * The runbook / demo path no longer needs `port-forward` + a hand-launched diff --git a/docs/reference-stack/scripts/default_install_smoke.sh b/docs/reference-stack/scripts/default_install_smoke.sh index a838d0b3..a58cb473 100755 --- a/docs/reference-stack/scripts/default_install_smoke.sh +++ b/docs/reference-stack/scripts/default_install_smoke.sh @@ -1340,6 +1340,86 @@ until has_reason_code "$policy_high_resp" "PREFIX_MATCH" \ done log "CachePolicy push adopted: above-threshold lookup hit; below-request-gate lookup returned AFFINITY_HINT; trivial-match (matched_tokens&2 || true + fail "grpcurl ReportCacheState did not accept the adapter-scoped smoke prefix" +} +log "ReportCacheState (adapter-scoped) response: $adapter_report_resp" + +adapter_same_payload="$(cat <&2 + echo "$adapter_same_resp" >&2 + if [ -s "$LOG_DIR/grpcurl-adapter-same.err" ]; then + cat "$LOG_DIR/grpcurl-adapter-same.err" >&2 + fi + fail "LookupRoute with the ingesting adapter_id did not return PREFIX_MATCH within ${POLICY_PUSH_TIMEOUT}s — the adapter partition must not break matching inside it" + fi + sleep 2 +done + +adapter_other_resp="$(grpcurl_lookup_route "$adapter_other_payload" "$LOG_DIR/grpcurl-adapter-other.err")" || true +if has_reason_code "$adapter_other_resp" "PREFIX_MATCH"; then + echo "different-adapter LookupRoute response (want NOT PREFIX_MATCH):" >&2 + echo "$adapter_other_resp" >&2 + fail "LookupRoute for adapter_id=smoke-lora-b matched a prefix ingested under smoke-lora-a — identical token content aliased across adapters, which would route to a replica holding a DIFFERENT adapter's KV" +fi + +adapter_none_resp="$(grpcurl_lookup_route "$adapter_none_payload" "$LOG_DIR/grpcurl-adapter-none.err")" || true +if has_reason_code "$adapter_none_resp" "PREFIX_MATCH"; then + echo "no-adapter LookupRoute response (want NOT PREFIX_MATCH):" >&2 + echo "$adapter_none_resp" >&2 + fail "LookupRoute without adapter_id matched an adapter-scoped prefix — adapter-scoped ingest must stay out of the default partition" +fi +log "adapter partition enforced end-to-end: same adapter_id → PREFIX_MATCH; a different adapter_id and an absent adapter_id both stayed off the prefix-match path on the identical prefix hash" + # --- routingFloorScore end-to-end probe ------------------------------------ # Proves the new field flows CR → controller flatten → /policy push → server # resolver → buildLookupResponse downgrade. The same 64-token prefix that @@ -3643,4 +3723,4 @@ log "Mooncake engine pod: hostNetwork=true, dnsPolicy=ClusterFirstWithHostNet, w kubectl delete namespace "$MOONCAKE_SMOKE_NS" --ignore-not-found --wait=false >/dev/null 2>&1 || true -log "PASS — install bundle came up, CacheIndex + CacheTenant status writing, PromptTemplate + PDTopology schema-only surfaces, server HTTP surface, CachePolicy push adoption, gRPC fail-open (plaintext default), CacheBackend ↔ engine-pod binding signals + drift cadence, spec.resources defaults + thread-through, External backend end-to-end, /snapshot + /policy + /probe unauth rejection, audience binding on all three endpoints, the opt-in gRPC TLS overlay (incl. the existing LookupRoute call pattern over TLS), kernel-check injection shape + report-only FAIL condition path (EngineKernelsHealthy=False/KernelLoadFailed), the operator 'inferencecache doctor' CLI against the live install, the managed Mooncake backend provisioning contract (stand-in master reaches Available on hostNetwork behind a headless Service, Recreate strategy, mooncakestore:// RPC endpoint in status) plus the engineHostNetwork opt-in end-to-end (warning fires only without it; a matched engine pod is admitted onto hostNetwork with ClusterFirstWithHostNet and the mooncakestore:// connector, while a non-Mooncake engine pod stays on the pod network; real engine KV transfer is NOT exercised here), and every config/samples/ manifest applies cleanly — all work" +log "PASS — install bundle came up, CacheIndex + CacheTenant status writing, PromptTemplate + PDTopology schema-only surfaces, server HTTP surface, CachePolicy push adoption, gRPC fail-open (plaintext default), adapter (LoRA) index partitioning on LookupRoute, CacheBackend ↔ engine-pod binding signals + drift cadence, spec.resources defaults + thread-through, External backend end-to-end, /snapshot + /policy + /probe unauth rejection, audience binding on all three endpoints, the opt-in gRPC TLS overlay (incl. the existing LookupRoute call pattern over TLS), kernel-check injection shape + report-only FAIL condition path (EngineKernelsHealthy=False/KernelLoadFailed), the operator 'inferencecache doctor' CLI against the live install, the managed Mooncake backend provisioning contract (stand-in master reaches Available on hostNetwork behind a headless Service, Recreate strategy, mooncakestore:// RPC endpoint in status) plus the engineHostNetwork opt-in end-to-end (warning fires only without it; a matched engine pod is admitted onto hostNetwork with ClusterFirstWithHostNet and the mooncakestore:// connector, while a non-Mooncake engine pod stays on the pod network; real engine KV transfer is NOT exercised here), and every config/samples/ manifest applies cleanly — all work" diff --git a/pkg/adapters/engine/config.go b/pkg/adapters/engine/config.go index 6ae00197..40b0cb3f 100644 --- a/pkg/adapters/engine/config.go +++ b/pkg/adapters/engine/config.go @@ -1,6 +1,10 @@ package engine -import "fmt" +import ( + "fmt" + "strconv" + "strings" +) // Config is the per-engine-replica identity the subscriber stamps onto every // report. vLLM's KV-cache events carry none of this, so the subscriber must be @@ -18,6 +22,80 @@ type Config struct { // and non-empty: an empty scheme fails open server-side (dropped on ingest), // so reporting with one would silently lose the data. HashScheme string + // AdapterNames maps the engine's INTERNAL LoRA id (BlockStored.lora_id) to + // the stable adapter identity used as the index partition — the same string + // a gateway puts in LookupRouteRequest.adapter_id. + // + // The mapping is needed because the engine's id is a load-order integer, not + // a name: vLLM assigns lora_int_id from the ordered --lora-modules list (and + // increments it for adapters loaded at runtime), so the SAME integer can mean + // different adapters on two replicas whose load order differs. The index is + // shared across replicas, so partitioning on the raw integer would re-create + // the aliasing this partition exists to prevent — across replicas instead of + // within one. + // + // Optional. An id with no mapping falls back to "lora:", which is exact + // within a replica and agrees ACROSS replicas whenever they share the same + // adapter load order (the homogeneous-Deployment case). Supply the map — from + // the same --lora-modules ordering the engine gets — for deployments whose + // replicas can diverge (runtime load/unload, rolling updates), and supply the + // matching adapter_id from the gateway. Nil/empty is the common single-adapter + // or LoRA-free deployment: every event has no lora_id and lands in the default + // ("") partition, i.e. exactly the pre-adapter behavior. + AdapterNames map[int64]string +} + +// AdapterID resolves an engine LoRA id to the stable adapter identity that +// partitions the index. A nil id (base model / no adapter) yields "" — the +// default partition, which is the behavior for every non-LoRA deployment. +// A mapped id yields its configured name; an unmapped one yields "lora:" +// (see AdapterNames for why that fallback is exact per-replica but only +// load-order-stable across replicas). +func (c Config) AdapterID(loraID *int64) string { + if loraID == nil { + return "" + } + if name, ok := c.AdapterNames[*loraID]; ok && name != "" { + return name + } + return "lora:" + strconv.FormatInt(*loraID, 10) +} + +// ParseAdapterNames parses a comma-separated "id=name" list into the +// Config.AdapterNames map (e.g. "1=sql-lora,2=chat-lora"). Empty input yields a +// nil map — no mapping, so every adapter id falls back to "lora:". Blank +// list entries are skipped so a trailing comma is tolerated; a malformed pair, +// a non-integer id, an empty name, or a duplicate id is an error, because +// silently dropping one would put that adapter's blocks in a different partition +// than the gateway looks up and turn every one of its lookups into a miss. +func ParseAdapterNames(s string) (map[int64]string, error) { + var out map[int64]string + for _, pair := range strings.Split(s, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + idStr, name, ok := strings.Cut(pair, "=") + if !ok { + return nil, fmt.Errorf("adapter names: %q is not id=name", pair) + } + id, err := strconv.ParseInt(strings.TrimSpace(idStr), 10, 64) + if err != nil { + return nil, fmt.Errorf("adapter names: %q has a non-integer lora id: %w", pair, err) + } + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("adapter names: %q has an empty adapter name", pair) + } + if _, dup := out[id]; dup { + return nil, fmt.Errorf("adapter names: lora id %d mapped more than once", id) + } + if out == nil { + out = make(map[int64]string) + } + out[id] = name + } + return out, nil } // Validate checks the required identity fields are set. diff --git a/pkg/adapters/engine/events.go b/pkg/adapters/engine/events.go index 747432b0..7b903f47 100644 --- a/pkg/adapters/engine/events.go +++ b/pkg/adapters/engine/events.go @@ -23,7 +23,10 @@ import ( // parent_block_hash. The subscriber uses those two LOCALLY, in-pod, to derive its // own deterministic content fingerprint for the index key (see positional.go) — // the token IDs are hashed and never leave the pod, so only hashes ever reach the -// server and the control plane stays metadata-only. lora_id is still ignored. +// server and the control plane stays metadata-only. lora_id IS decoded (see +// BlockStored.LoRAID): the fingerprint is token-only, so two prompts with the +// same tokens under different adapters hash identically and would alias in the +// index — the id becomes the index PARTITION, never part of the hash. // Event is one decoded KV-cache event. The concrete types are BlockStored, // BlockRemoved, and AllBlocksCleared. @@ -44,11 +47,18 @@ type Event interface{ isEvent() } // TokenIDs are the flat token IDs of this event's blocks (BlockSize tokens per // block, in block order). They are hashed in-pod to derive the fingerprint and // never leave the pod. +// +// LoRAID is the engine's LoRA adapter id for these blocks — nil when the event +// carries no adapter (msgpack nil, a truncated 5-field tuple, or a base-model +// request). It is the engine's INTERNAL integer id, assigned in adapter load +// order, so Config.AdapterID maps it to the stable adapter identity that becomes +// the index partition; it is never mixed into the content fingerprint. type BlockStored struct { BlockHashes [][]byte ParentBlockHash []byte TokenIDs []uint32 BlockSize int32 + LoRAID *int64 } // BlockRemoved reports KV blocks that were evicted. BlockHashes are opaque bytes @@ -148,11 +158,22 @@ func decodeEvent(raw msgpack.RawMessage) (Event, error) { if bs <= 0 { return nil, fmt.Errorf("BlockStored.block_size must be positive, got %d", bs) } + // lora_id is OPTIONAL on the wire: the 5-field tuple (no adapter field at + // all) and an explicit nil both mean "no adapter", so a truncated tuple is + // not an error here — only a present-but-undecodable value is. + var loraID *int64 + if len(fields) >= 6 { + loraID, err = decodeLoRAID(fields[5]) + if err != nil { + return nil, fmt.Errorf("BlockStored.lora_id: %w", err) + } + } return BlockStored{ BlockHashes: hashes, ParentBlockHash: parent, TokenIDs: tokenIDs, BlockSize: bs, + LoRAID: loraID, }, nil case "BlockRemoved": // [tag, block_hashes] @@ -240,6 +261,41 @@ func decodeParent(raw msgpack.RawMessage) ([]byte, error) { return hashToBytes(v) } +// decodeLoRAID decodes the optional lora_id. A msgpack nil (or an absent field) +// yields nil — no adapter. Anything present but not an integer is an error +// rather than a silent "no adapter": treating an unrecognized adapter id as the +// base model would put that adapter's blocks in the default partition, where +// they alias against every other adapter's identical token content — the exact +// failure this field exists to prevent. Dropping the batch loses a routing hint +// (soft state, a cache miss at worst); aliasing returns a wrong hint. +func decodeLoRAID(raw msgpack.RawMessage) (*int64, error) { + if len(raw) == 0 { + return nil, nil // a msgpack nil decodes to an empty RawMessage — no adapter + } + var v interface{} + if err := msgpack.Unmarshal(raw, &v); err != nil { + return nil, err + } + if v == nil { + return nil, nil + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + id := rv.Int() + return &id, nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + u := rv.Uint() + if u > math.MaxInt64 { + return nil, fmt.Errorf("lora id %d out of int64 range", u) + } + id := int64(u) + return &id, nil + default: + return nil, fmt.Errorf("unsupported lora_id type %T", v) + } +} + // decodeTokenIDs decodes the flat token_ids array to uint32 (vLLM token IDs fit in // 32 bits). Hashed in-pod for the content fingerprint; never forwarded. func decodeTokenIDs(raw msgpack.RawMessage) ([]uint32, error) { diff --git a/pkg/adapters/engine/forwarder.go b/pkg/adapters/engine/forwarder.go index d9ef79cb..0bf74d0b 100644 --- a/pkg/adapters/engine/forwarder.go +++ b/pkg/adapters/engine/forwarder.go @@ -114,7 +114,11 @@ func (r *Reporter) Run(ctx context.Context, in <-chan *EventBatch) error { for _, ev := range b.Events { switch e := ev.(type) { case BlockStored: - entries := r.pos.Stored(e) + // Resolve the engine's internal LoRA id to the stable adapter + // identity that partitions the index (see Config.AdapterID). + // nil id → "" → the default partition, i.e. exactly the + // pre-adapter behavior for every non-LoRA deployment. + entries := r.pos.Stored(e, r.cfg.AdapterID(e.LoRAID)) if len(entries) == 0 && len(e.BlockHashes) > 0 { // A BlockStored carrying block hashes produced no index // entries — either token_ids are absent (engine not emitting @@ -156,8 +160,8 @@ func (r *Reporter) Run(ctx context.Context, in <-chan *EventBatch) error { // same block (store-then-evict within one window). Flush pending // adds first to preserve store→evict order. flush() - for _, ourHash := range r.pos.Removed(e) { - r.publish(r.cfg.EvictedEvent(ourHash, b.TimestampSeconds)) + for _, ev := range r.pos.Removed(e) { + r.publish(r.cfg.EvictedEvent(ev.PrefixHash, ev.AdapterID, b.TimestampSeconds)) } case AllBlocksCleared: pending = pending[:0] // a clear supersedes buffered adds diff --git a/pkg/adapters/engine/lora_adapter_test.go b/pkg/adapters/engine/lora_adapter_test.go new file mode 100644 index 00000000..7f396171 --- /dev/null +++ b/pkg/adapters/engine/lora_adapter_test.go @@ -0,0 +1,240 @@ +package engine + +import ( + "testing" + + "github.com/cachebox-project/inference-cache/pkg/fingerprint" +) + +// vLLM's BlockStored tuple has always carried lora_id as its trailing field +// ([tag, block_hashes, parent_block_hash, token_ids, block_size, lora_id]); the +// decoder used to read through block_size and drop it. It is now decoded, +// because the content fingerprint is token-only: identical tokens under two +// adapters hash identically, so the id is what tells the index which partition +// the blocks belong in. + +func TestDecodeBlockStoredCarriesLoRAID(t *testing.T) { + payload := encodeVLLMBatch(t, 1.0, + []interface{}{"BlockStored", []uint64{10}, nil, []int64{1, 2}, int32(2), int64(7)}, + ) + batch, err := DecodeEventBatch(payload) + if err != nil { + t.Fatalf("DecodeEventBatch: %v", err) + } + bs, ok := batch.Events[0].(BlockStored) + if !ok { + t.Fatalf("event 0 = %T, want BlockStored", batch.Events[0]) + } + if bs.LoRAID == nil { + t.Fatal("LoRAID is nil, want 7 — the adapter id is on the wire and must not be dropped") + } + if *bs.LoRAID != 7 { + t.Errorf("LoRAID = %d, want 7", *bs.LoRAID) + } +} + +// A nil lora_id (base-model request) and a truncated 5-field tuple both mean +// "no adapter" — neither is an error, and both land in the default partition. +func TestDecodeBlockStoredWithoutLoRAID(t *testing.T) { + for name, ev := range map[string][]interface{}{ + "explicit nil": {"BlockStored", []uint64{10}, nil, []int64{1, 2}, int32(2), nil}, + "field absent (5/6)": {"BlockStored", []uint64{10}, nil, []int64{1, 2}, int32(2)}, + } { + t.Run(name, func(t *testing.T) { + batch, err := DecodeEventBatch(encodeVLLMBatch(t, 1.0, ev)) + if err != nil { + t.Fatalf("DecodeEventBatch: %v", err) + } + if bs := batch.Events[0].(BlockStored); bs.LoRAID != nil { + t.Errorf("LoRAID = %d, want nil", *bs.LoRAID) + } + }) + } +} + +// A present-but-unrecognized lora_id must fail the batch rather than decode to +// "no adapter": mislabelling an adapter's blocks as base-model puts them in the +// default partition, where they alias against every other adapter's identical +// token content — the exact bug the partition exists to prevent. Losing the +// batch costs a routing hint (soft state); aliasing returns a wrong hint. +func TestDecodeBlockStoredRejectsUndecodableLoRAID(t *testing.T) { + payload := encodeVLLMBatch(t, 1.0, + []interface{}{"BlockStored", []uint64{10}, nil, []int64{1, 2}, int32(2), []int64{1, 2}}, + ) + if _, err := DecodeEventBatch(payload); err == nil { + t.Fatal("DecodeEventBatch accepted a non-integer lora_id; want an error so the batch is dropped, not silently un-partitioned") + } +} + +func TestConfigAdapterID(t *testing.T) { + cfg := testConfig() + cfg.AdapterNames = map[int64]string{1: "sql-lora"} + + if got := cfg.AdapterID(nil); got != "" { + t.Errorf("AdapterID(nil) = %q, want \"\" (base model → default partition)", got) + } + one, two := int64(1), int64(2) + if got := cfg.AdapterID(&one); got != "sql-lora" { + t.Errorf("AdapterID(1) = %q, want sql-lora", got) + } + // Unmapped ids stay exact within a replica via the "lora:" fallback — + // they must never collapse to "" (which would alias with the base model). + if got := cfg.AdapterID(&two); got != "lora:2" { + t.Errorf("AdapterID(2) = %q, want lora:2", got) + } + if got := (Config{}).AdapterID(&one); got != "lora:1" { + t.Errorf("AdapterID with no map = %q, want lora:1", got) + } +} + +func TestParseAdapterNames(t *testing.T) { + got, err := ParseAdapterNames(" 1=sql-lora, 2 = chat-lora ,") + if err != nil { + t.Fatalf("ParseAdapterNames: %v", err) + } + if len(got) != 2 || got[1] != "sql-lora" || got[2] != "chat-lora" { + t.Errorf("parsed = %v, want {1: sql-lora, 2: chat-lora}", got) + } + if empty, err := ParseAdapterNames(""); err != nil || empty != nil { + t.Errorf("ParseAdapterNames(\"\") = %v, %v; want nil, nil", empty, err) + } + for _, bad := range []string{"sql-lora", "x=sql-lora", "1=", "1=a,1=b"} { + if _, err := ParseAdapterNames(bad); err == nil { + t.Errorf("ParseAdapterNames(%q) accepted a malformed mapping; a dropped entry would silently mis-partition that adapter", bad) + } + } +} + +// Stored stamps the resolved adapter on every emitted entry and remembers it per +// block, so a later eviction can name the right partition. +func TestStoredStampsAdapterAndRemovedReportsIt(t *testing.T) { + const bs = 16 + toks := tokSeq(1, bs) + p := newPositionalIndex() + + entries := p.Stored(BlockStored{BlockHashes: [][]byte{engHash(5)}, TokenIDs: toks, BlockSize: bs}, "sql-lora") + if len(entries) != 1 || entries[0].GetAdapterId() != "sql-lora" { + t.Fatalf("entries = %+v, want one entry stamped sql-lora", entries) + } + // The fingerprint itself must be untouched by the adapter — adapter identity + // lives in the partition, never in the hash. + if beU64(entries[0].PrefixHash) != fingerprint.ContentHash(toks) { + t.Error("prefix hash changed under an adapter; the fingerprint must stay a pure function of token content") + } + + rm := p.Removed(BlockRemoved{BlockHashes: [][]byte{engHash(5)}}) + if len(rm) != 1 || rm[0].AdapterID != "sql-lora" { + t.Fatalf("Removed = %+v, want the sql-lora partition named", rm) + } +} + +// Two adapters caching the SAME tokens produce the same prefix hash but must be +// reported under different partitions — this is the aliasing case, checked at +// the subscriber boundary. +func TestStoredSameTokensDifferentAdaptersShareHashNotPartition(t *testing.T) { + const bs = 16 + toks := tokSeq(1, bs) + + sql := newPositionalIndex().Stored(BlockStored{BlockHashes: [][]byte{engHash(5)}, TokenIDs: toks, BlockSize: bs}, "sql-lora") + chat := newPositionalIndex().Stored(BlockStored{BlockHashes: [][]byte{engHash(6)}, TokenIDs: toks, BlockSize: bs}, "chat-lora") + + if beU64(sql[0].PrefixHash) != beU64(chat[0].PrefixHash) { + t.Fatal("precondition failed: the token-only fingerprint should be identical for both adapters") + } + if sql[0].GetAdapterId() == chat[0].GetAdapterId() { + t.Errorf("both entries reported adapter %q — identical hashes with an identical partition is the alias", sql[0].GetAdapterId()) + } +} + +// End-to-end through the Reporter: one batch carrying two adapters' blocks is +// forwarded as entries stamped with each adapter's resolved identity, and the +// eviction that follows names the partition it came from. +func TestReporterPartitionsEntriesByLoRAID(t *testing.T) { + const bs = 16 + one, two := int64(1), int64(2) + toks := tokSeq(100, bs) + + cfg := testConfig() + cfg.AdapterNames = map[int64]string{1: "sql-lora"} + + rec := runReporterCfg(t, cfg, nil, + &EventBatch{TimestampSeconds: 2.0, Events: []Event{ + BlockStored{BlockHashes: [][]byte{{0x0a}}, TokenIDs: toks, BlockSize: bs, LoRAID: &one}, + BlockStored{BlockHashes: [][]byte{{0x0b}}, TokenIDs: toks, BlockSize: bs, LoRAID: &two}, + BlockStored{BlockHashes: [][]byte{{0x0c}}, TokenIDs: toks, BlockSize: bs}, // base model + }}, + &EventBatch{TimestampSeconds: 3.0, Events: []Event{ + BlockRemoved{BlockHashes: [][]byte{{0x0a}}}, + }}, + ) + + rec.mu.Lock() + defer rec.mu.Unlock() + + var adapters []string + for _, u := range rec.updates { + if u.GetAdapterId() != "" { + t.Errorf("update-level adapter_id = %q, want empty — a multi-adapter batch must stamp entries, not the update", u.GetAdapterId()) + } + for _, p := range u.GetPrefixes() { + adapters = append(adapters, p.GetAdapterId()) + } + } + want := map[string]bool{"sql-lora": false, "lora:2": false, "": false} + for _, a := range adapters { + if _, known := want[a]; !known { + t.Errorf("unexpected adapter_id %q on a forwarded entry", a) + continue + } + want[a] = true + } + for a, seen := range want { + if !seen { + t.Errorf("no entry forwarded for adapter %q; got %v", a, adapters) + } + } + + var evictions int + for _, ev := range rec.events { + if ev.GetPrefixHash() == nil { + continue + } + evictions++ + if ev.GetAdapterId() != "sql-lora" { + t.Errorf("eviction adapter_id = %q, want sql-lora — evicting block 0x0a must not drop the other adapters' identical hash", ev.GetAdapterId()) + } + } + if evictions != 1 { + t.Errorf("got %d evictions, want 1", evictions) + } +} + +// A deployment with no LoRA at all is byte-for-byte what it was before: every +// event has a nil lora_id, so every entry and eviction stays in the default +// partition. +func TestReporterWithoutLoRAKeepsDefaultPartition(t *testing.T) { + const bs = 16 + rec := runReporter(t, + &EventBatch{TimestampSeconds: 2.0, Events: []Event{ + BlockStored{BlockHashes: [][]byte{{0x0a}}, TokenIDs: tokSeq(1, bs), BlockSize: bs}, + }}, + &EventBatch{TimestampSeconds: 3.0, Events: []Event{ + BlockRemoved{BlockHashes: [][]byte{{0x0a}}}, + }}, + ) + + rec.mu.Lock() + defer rec.mu.Unlock() + for _, u := range rec.updates { + for _, p := range u.GetPrefixes() { + if p.GetAdapterId() != "" { + t.Errorf("adapter_id = %q on a non-LoRA deployment, want empty", p.GetAdapterId()) + } + } + } + for _, ev := range rec.events { + if ev.GetAdapterId() != "" { + t.Errorf("eviction adapter_id = %q on a non-LoRA deployment, want empty", ev.GetAdapterId()) + } + } +} diff --git a/pkg/adapters/engine/mapper.go b/pkg/adapters/engine/mapper.go index ca8c6258..c4c6e565 100644 --- a/pkg/adapters/engine/mapper.go +++ b/pkg/adapters/engine/mapper.go @@ -24,6 +24,12 @@ func microsFromSeconds(s float64) int64 { // Update stamps the replica/model/tenant/hash_scheme identity onto a set of // prefixes. Returns nil for an empty prefix set (nothing to report). +// +// The update-level adapter_id is deliberately left empty: one replica can hold +// KV for several adapters at once and the Reporter batches events across them +// into a single update, so adapter identity is stamped PER ENTRY in +// positionalIndex.Stored. The server treats the update-level field only as a +// default for entries that set none, so leaving it empty is correct here. func (c Config) Update(tsUs int64, prefixes []*icpb.PrefixEntry) *icpb.CacheStateUpdate { if len(prefixes) == 0 { return nil @@ -81,15 +87,18 @@ func (c Config) ClearedEvent(tsSeconds float64) *icpb.CacheEvent { } // EvictedEvent builds one PREFIX_EVICTED CacheEvent for an already-derived prefix -// hash (our content fingerprint, the index key to drop). The subscriber maps an -// evicted engine block hash to this key via positionalIndex.Removed. -func (c Config) EvictedEvent(prefixHash []byte, tsSeconds float64) *icpb.CacheEvent { +// hash (our content fingerprint, the index key to drop) in a specific adapter +// partition. The subscriber maps an evicted engine block hash to both via +// positionalIndex.Removed. An empty adapterID is the default partition; the +// server then falls back to its cross-partition (legacy) removal. +func (c Config) EvictedEvent(prefixHash []byte, adapterID string, tsSeconds float64) *icpb.CacheEvent { return &icpb.CacheEvent{ Type: icpb.CacheEvent_PREFIX_EVICTED, ReplicaId: c.ReplicaID, ModelId: c.ModelID, TenantId: c.TenantID, PrefixHash: prefixHash, + AdapterId: adapterID, TimestampUs: microsFromSeconds(tsSeconds), } } diff --git a/pkg/adapters/engine/mapper_test.go b/pkg/adapters/engine/mapper_test.go index b3319752..f87faba8 100644 --- a/pkg/adapters/engine/mapper_test.go +++ b/pkg/adapters/engine/mapper_test.go @@ -12,7 +12,7 @@ func testConfig() Config { } func TestEvictedEvent(t *testing.T) { - e := testConfig().EvictedEvent([]byte{0x01, 0x02}, 1.0) + e := testConfig().EvictedEvent([]byte{0x01, 0x02}, "", 1.0) if e.Type != icpb.CacheEvent_PREFIX_EVICTED { t.Errorf("type = %v, want PREFIX_EVICTED", e.Type) } @@ -22,6 +22,18 @@ func TestEvictedEvent(t *testing.T) { if !bytes.Equal(e.PrefixHash, []byte{0x01, 0x02}) { t.Errorf("prefix hash = %x, want 0102", e.PrefixHash) } + if e.AdapterId != "" { + t.Errorf("adapter id = %q, want \"\" (no adapter)", e.AdapterId) + } +} + +// An adapter-scoped eviction must name its partition, so the server drops the +// prefix only there — the same hash can be live under another adapter. +func TestEvictedEventCarriesAdapter(t *testing.T) { + e := testConfig().EvictedEvent([]byte{0x01}, "sql-lora", 1.0) + if e.AdapterId != "sql-lora" { + t.Errorf("adapter id = %q, want sql-lora", e.AdapterId) + } } func TestClearedEvent(t *testing.T) { diff --git a/pkg/adapters/engine/positional.go b/pkg/adapters/engine/positional.go index a082cabd..13c58428 100644 --- a/pkg/adapters/engine/positional.go +++ b/pkg/adapters/engine/positional.go @@ -27,6 +27,18 @@ type positionalIndex struct { type posEntry struct { prefixHash uint64 // our rolling positional prefix hash tokenCount int32 // cumulative tokens of the prefix up to and including this block + // adapter is the resolved adapter identity (Config.AdapterID) the block was + // stored under — "" for the base model. Remembered per block so an eviction, + // which only names the engine block hash, can target the right index + // partition instead of dropping the same prefix hash under every adapter. + adapter string +} + +// EvictedPrefix is one index key to drop: our content-fingerprint prefix hash +// plus the adapter partition it lives in. +type EvictedPrefix struct { + PrefixHash []byte + AdapterID string } func newPositionalIndex() *positionalIndex { @@ -46,7 +58,22 @@ func newPositionalIndex() *positionalIndex { // cost (a wrong hint degrades to a cache miss, never a wrong answer) and does not // occur on a clean cold start (no gaps). Hardening — learn NONE_HASH and drop // true gaps — is tracked as a follow-up. Returns nil when nothing is indexable. -func (p *positionalIndex) Stored(ev BlockStored) []*icpb.PrefixEntry { +// +// adapterID is the resolved adapter identity for this event (Config.AdapterID of +// ev.LoRAID); "" is the base model / default partition. It is stamped on every +// emitted PrefixEntry so the server partitions the index by adapter, and +// remembered per block so a later eviction targets the same partition. It is +// deliberately NOT folded into the prefix hash: the fingerprint stays a pure +// function of token content (and its golden vectors stay valid) — adapter +// identity separates the KEYSPACE instead. +// +// Chaining is adapter-scoped in practice: the engine only reports a parent block +// that its own cache holds, and a block's KV belongs to exactly one adapter, so a +// parent found in the reverse map was stored under this same adapter. The chain +// is still allowed to proceed on a parent hit regardless — a defensive +// adapter-mismatch drop would silently degrade every extension to a fresh root, +// and the entry it produces is written to THIS event's partition either way. +func (p *positionalIndex) Stored(ev BlockStored, adapterID string) []*icpb.PrefixEntry { bs := int(ev.BlockSize) if bs <= 0 || len(ev.BlockHashes) == 0 { return nil @@ -78,24 +105,38 @@ func (p *positionalIndex) Stored(ev BlockStored) []*icpb.PrefixEntry { out = append(out, &icpb.PrefixEntry{ PrefixHash: fingerprint.Bytes(phs[i]), TokenCount: tokens, + AdapterId: adapterID, }) - p.blocks[string(ev.BlockHashes[i])] = posEntry{prefixHash: phs[i], tokenCount: tokens} + p.blocks[string(ev.BlockHashes[i])] = posEntry{ + prefixHash: phs[i], + tokenCount: tokens, + adapter: adapterID, + } } return out } -// Removed maps each evicted engine block hash to our prefix hash (the index key to -// drop) and forgets it. Unknown hashes are skipped. The caller turns each returned -// hash into a PREFIX_EVICTED event via Config.EvictedEvent. -func (p *positionalIndex) Removed(ev BlockRemoved) [][]byte { +// Removed maps each evicted engine block hash to our prefix hash (the index key +// to drop) PLUS the adapter partition it was stored under, and forgets it. +// Unknown hashes are skipped. The caller turns each returned pair into a +// PREFIX_EVICTED event via Config.EvictedEvent. +// +// Carrying the adapter matters here: the fingerprint is token-only, so the same +// prefix hash can be live under several adapters at once. Without the partition +// the server would drop all of them for one adapter's GPU eviction, throwing +// away routing hints that are still valid. +func (p *positionalIndex) Removed(ev BlockRemoved) []EvictedPrefix { if len(ev.BlockHashes) == 0 { return nil } - out := make([][]byte, 0, len(ev.BlockHashes)) + out := make([]EvictedPrefix, 0, len(ev.BlockHashes)) for _, h := range ev.BlockHashes { key := string(h) if pe, ok := p.blocks[key]; ok { - out = append(out, fingerprint.Bytes(pe.prefixHash)) + out = append(out, EvictedPrefix{ + PrefixHash: fingerprint.Bytes(pe.prefixHash), + AdapterID: pe.adapter, + }) delete(p.blocks, key) } } diff --git a/pkg/adapters/engine/positional_test.go b/pkg/adapters/engine/positional_test.go index acb33c07..4e089fc8 100644 --- a/pkg/adapters/engine/positional_test.go +++ b/pkg/adapters/engine/positional_test.go @@ -37,14 +37,14 @@ func TestStoredChainMatchesFullRoll(t *testing.T) { BlockHashes: [][]byte{engHash(10), engHash(11), engHash(12)}, TokenIDs: full[:3*bs], BlockSize: bs, - }) + }, "") // Event 2: incremental, blocks 3..4, parent = engine hash of block 2. got2 := p.Stored(BlockStored{ BlockHashes: [][]byte{engHash(13), engHash(14)}, ParentBlockHash: engHash(12), TokenIDs: full[3*bs : 5*bs], BlockSize: bs, - }) + }, "") var got []uint64 for _, e := range got1 { @@ -72,7 +72,7 @@ func TestStoredFreshPrefixZero(t *testing.T) { const bs = 16 toks := tokSeq(1, bs) p := newPositionalIndex() - got := p.Stored(BlockStored{BlockHashes: [][]byte{engHash(1)}, TokenIDs: toks, BlockSize: bs}) + got := p.Stored(BlockStored{BlockHashes: [][]byte{engHash(1)}, TokenIDs: toks, BlockSize: bs}, "") if len(got) != 1 || beU64(got[0].PrefixHash) != fingerprint.ContentHash(toks) { t.Fatal("fresh prefix[0] != content[0]") } @@ -89,7 +89,7 @@ func TestStoredUnknownParentStartsFresh(t *testing.T) { ParentBlockHash: engHash(0xDEAD), // never stored TokenIDs: toks, BlockSize: bs, - }) + }, "") if len(got) != 1 || beU64(got[0].PrefixHash) != fingerprint.ContentHash(toks) { t.Fatal("unknown parent did not start a fresh sequence") } @@ -100,13 +100,16 @@ func TestRemovedMapsAndForgets(t *testing.T) { const bs = 16 toks := tokSeq(1, bs) p := newPositionalIndex() - stored := p.Stored(BlockStored{BlockHashes: [][]byte{engHash(5)}, TokenIDs: toks, BlockSize: bs}) + stored := p.Stored(BlockStored{BlockHashes: [][]byte{engHash(5)}, TokenIDs: toks, BlockSize: bs}, "") our := stored[0].PrefixHash rm := p.Removed(BlockRemoved{BlockHashes: [][]byte{engHash(5)}}) - if len(rm) != 1 || beU64(rm[0]) != beU64(our) { + if len(rm) != 1 || beU64(rm[0].PrefixHash) != beU64(our) { t.Fatalf("Removed = %v, want our prefix hash %d", rm, beU64(our)) } + if rm[0].AdapterID != "" { + t.Errorf("AdapterID = %q, want \"\" (block stored without an adapter)", rm[0].AdapterID) + } // Re-removing the now-forgotten hash yields nothing. if rm2 := p.Removed(BlockRemoved{BlockHashes: [][]byte{engHash(5)}}); len(rm2) != 0 { t.Fatalf("re-remove returned %d, want 0", len(rm2)) @@ -119,7 +122,7 @@ func TestClearedResets(t *testing.T) { const bs = 16 full := tokSeq(1, 2*bs) p := newPositionalIndex() - p.Stored(BlockStored{BlockHashes: [][]byte{engHash(1), engHash(2)}, TokenIDs: full, BlockSize: bs}) + p.Stored(BlockStored{BlockHashes: [][]byte{engHash(1), engHash(2)}, TokenIDs: full, BlockSize: bs}, "") p.Cleared() suffix := tokSeq(500, bs) got := p.Stored(BlockStored{ @@ -127,7 +130,7 @@ func TestClearedResets(t *testing.T) { ParentBlockHash: engHash(2), // gone after Cleared TokenIDs: suffix, BlockSize: bs, - }) + }, "") if len(got) != 1 || beU64(got[0].PrefixHash) != fingerprint.ContentHash(suffix) { t.Fatal("after Cleared, a stale parent should have forced a fresh sequence") } diff --git a/pkg/index/adapter_partition_test.go b/pkg/index/adapter_partition_test.go new file mode 100644 index 00000000..bfdb7b51 --- /dev/null +++ b/pkg/index/adapter_partition_test.go @@ -0,0 +1,269 @@ +package index + +import "testing" + +// The content fingerprint is derived from token IDs ALONE, so two prompts with +// identical tokens under different LoRA adapters produce the SAME prefix hash. +// The index partition — (tenant, model, hash_scheme, adapter) — is what keeps +// them from aliasing onto one entry and handing a caller a replica that only +// holds the OTHER adapter's KV. +// +// The tests below pin the four properties that define the partition: +// +// 1. different adapters, same tokens -> no alias (the bug being fixed) +// 2. same adapter, same tokens -> still hits (the partition is not a hash) +// 3. absent adapter -> byte-identical to pre-adapter behavior +// 4. per-entry adapter beats the update-level default (multi-adapter producers) + +const ( + adapterTenant = "t" + adapterModel = "m" + adapterScheme = "vllm" +) + +// ingestUnder ingests one legacy single-blob prefix for a replica in an adapter +// partition. The prefix hash is deliberately shared across calls: identical +// tokens under different adapters is exactly the colliding input. +func ingestUnder(idx *Index, replica, adapter, prefix string) { + idx.Ingest(Update{ + ReplicaID: replica, + Model: adapterModel, + Tenant: adapterTenant, + HashScheme: adapterScheme, + Prefixes: []PrefixRef{{ + PrefixHash: hash(prefix), + TokenCount: 128, + Adapter: adapter, + }}, + }) +} + +func lookupUnder(idx *Index, adapter, prefix string) []ReplicaScore { + return idx.Lookup(LookupRequest{ + Tenant: adapterTenant, + Model: adapterModel, + HashScheme: adapterScheme, + Adapter: adapter, + PrefixHash: hash(prefix), + }) +} + +// Property 1 — the aliasing bug. Two replicas cache the same token content under +// different adapters. A lookup for one adapter must never surface the replica +// holding the other's KV, even though both entries carry the identical +// (token-derived) prefix hash. +func TestAdapterPartitionDoesNotAliasAcrossAdapters(t *testing.T) { + idx := New() + ingestUnder(idx, "replica-sql", "sql-lora", "same-tokens") + ingestUnder(idx, "replica-chat", "chat-lora", "same-tokens") + + sql := lookupUnder(idx, "sql-lora", "same-tokens") + if len(sql) != 1 || sql[0].ReplicaID != "replica-sql" { + t.Fatalf("sql-lora lookup = %+v, want exactly replica-sql — a hint for the chat-lora replica would route to a replica holding a DIFFERENT adapter's KV", sql) + } + chat := lookupUnder(idx, "chat-lora", "same-tokens") + if len(chat) != 1 || chat[0].ReplicaID != "replica-chat" { + t.Fatalf("chat-lora lookup = %+v, want exactly replica-chat", chat) + } +} + +// Property 2 — the partition is a partition, not a hash change: within one +// adapter the identical prefix hash still matches, and every replica holding it +// is still returned. +func TestAdapterPartitionSameAdapterStillHits(t *testing.T) { + idx := New() + ingestUnder(idx, "replica-a", "sql-lora", "same-tokens") + ingestUnder(idx, "replica-b", "sql-lora", "same-tokens") + + got := lookupUnder(idx, "sql-lora", "same-tokens") + if len(got) != 2 { + t.Fatalf("got %d scores, want both replicas in the sql-lora partition: %+v", len(got), got) + } + seen := map[string]bool{} + for _, sc := range got { + seen[sc.ReplicaID] = true + if sc.MatchedTokens != 128 { + t.Errorf("%s matched_tokens = %d, want 128 — partitioning must not change matching", sc.ReplicaID, sc.MatchedTokens) + } + } + if !seen["replica-a"] || !seen["replica-b"] { + t.Errorf("scores = %+v, want replica-a and replica-b", got) + } +} + +// Property 3 — backwards compatibility. An older producer and an older gateway +// both omit the adapter, land in the default ("") partition, and behave exactly +// as before. A partition-aware lookup must NOT reach into that default +// partition, and vice versa: producer and consumer have to agree on the +// identifier, the same rule hash_scheme already imposes. +func TestAdapterPartitionAbsentAdapterIsLegacyBehavior(t *testing.T) { + idx := New() + ingestUnder(idx, "legacy-replica", "", "same-tokens") + + if got := lookupUnder(idx, "", "same-tokens"); len(got) != 1 || got[0].ReplicaID != "legacy-replica" { + t.Fatalf("legacy (no-adapter) lookup = %+v, want legacy-replica — non-LoRA deployments must be unaffected", got) + } + if got := lookupUnder(idx, "sql-lora", "same-tokens"); len(got) != 0 { + t.Errorf("adapter lookup against legacy ingest = %+v, want no hint", got) + } + + // And the mirror image: adapter-scoped ingest is invisible to a legacy lookup. + idx2 := New() + ingestUnder(idx2, "lora-replica", "sql-lora", "same-tokens") + if got := lookupUnder(idx2, "", "same-tokens"); len(got) != 0 { + t.Errorf("legacy lookup against adapter ingest = %+v, want no hint", got) + } +} + +// Property 4 — a producer whose replica serves one adapter can stamp the update +// once; a multi-adapter producer stamps each entry and the per-entry value wins. +func TestAdapterPartitionUpdateLevelDefaultAndPerEntryOverride(t *testing.T) { + idx := New() + idx.Ingest(Update{ + ReplicaID: "replica-0", + Model: adapterModel, + Tenant: adapterTenant, + HashScheme: adapterScheme, + Adapter: "default-lora", + Prefixes: []PrefixRef{ + {PrefixHash: hash("inherits"), TokenCount: 64}, // no Adapter → update default + {PrefixHash: hash("overrides"), TokenCount: 64, Adapter: "other-lora"}, // per-entry wins + }, + }) + + if got := lookupUnder(idx, "default-lora", "inherits"); len(got) != 1 { + t.Errorf("entry without its own adapter did not inherit the update default: %+v", got) + } + if got := lookupUnder(idx, "other-lora", "overrides"); len(got) != 1 { + t.Errorf("per-entry adapter did not override the update default: %+v", got) + } + if got := lookupUnder(idx, "default-lora", "overrides"); len(got) != 0 { + t.Errorf("per-entry override leaked into the update-default partition: %+v", got) + } +} + +// The chain (block-hash) ingest/lookup path partitions identically — a chain +// expands into per-block entries and every one of them must land in the entry's +// adapter partition, or a longest-prefix match could still walk across adapters. +func TestAdapterPartitionChainPathDoesNotAlias(t *testing.T) { + idx := New() + blocks := [][]byte{hash("b0"), hash("b1")} + counts := []int32{64, 64} + + for _, tc := range []struct{ replica, adapter string }{ + {"replica-sql", "sql-lora"}, + {"replica-chat", "chat-lora"}, + } { + idx.Ingest(Update{ + ReplicaID: tc.replica, Model: adapterModel, Tenant: adapterTenant, HashScheme: adapterScheme, + Prefixes: []PrefixRef{{ + BlockHashes: blocks, BlockTokenCounts: counts, Adapter: tc.adapter, + }}, + }) + } + + res := idx.LookupRoute(LookupRequest{ + Tenant: adapterTenant, Model: adapterModel, HashScheme: adapterScheme, + Adapter: "sql-lora", BlockHashes: blocks, BlockTokenCounts: counts, + }) + if res.Strategy != StrategyPrefixMatch { + t.Fatalf("strategy = %v, want PrefixMatch within the sql-lora partition", res.Strategy) + } + if len(res.Scores) != 1 || res.Scores[0].ReplicaID != "replica-sql" { + t.Fatalf("chain scores = %+v, want only replica-sql — the chain walk must stay inside one adapter partition", res.Scores) + } + if res.Scores[0].MatchedTokens != 128 { + t.Errorf("matched_tokens = %d, want 128 (both blocks)", res.Scores[0].MatchedTokens) + } +} + +// A PREFIX_EVICTED that names its adapter drops the prefix ONLY there. Without +// the narrowing, one adapter's GPU eviction would wipe another adapter's still +// valid hint for the same (token-derived) hash. +func TestAdapterPartitionEvictionIsScopedToItsAdapter(t *testing.T) { + idx := New() + ingestUnder(idx, "replica-0", "sql-lora", "same-tokens") + ingestUnder(idx, "replica-0", "chat-lora", "same-tokens") + + idx.ApplyEvent(Event{ + Type: EventPrefixEvicted, ReplicaID: "replica-0", + Model: adapterModel, Tenant: adapterTenant, + PrefixHash: hash("same-tokens"), Adapter: "sql-lora", + }) + + if got := lookupUnder(idx, "sql-lora", "same-tokens"); len(got) != 0 { + t.Errorf("sql-lora entry survived its own eviction: %+v", got) + } + if got := lookupUnder(idx, "chat-lora", "same-tokens"); len(got) != 1 { + t.Errorf("chat-lora entry = %+v, want it untouched by the sql-lora eviction", got) + } +} + +// An eviction with NO adapter keeps the original conservative behavior: it +// sweeps every partition. That is what a pre-adapter producer emits, and for +// such a producer all entries live in the "" partition anyway — so the +// cross-partition sweep is indistinguishable from the old code there. +func TestAdapterPartitionEvictionWithoutAdapterSweepsEveryPartition(t *testing.T) { + idx := New() + ingestUnder(idx, "replica-0", "sql-lora", "same-tokens") + ingestUnder(idx, "replica-0", "", "same-tokens") + + idx.ApplyEvent(Event{ + Type: EventPrefixEvicted, ReplicaID: "replica-0", + Model: adapterModel, Tenant: adapterTenant, + PrefixHash: hash("same-tokens"), + }) + + if got := lookupUnder(idx, "", "same-tokens"); len(got) != 0 { + t.Errorf("default-partition entry survived an unscoped eviction: %+v", got) + } + if got := lookupUnder(idx, "sql-lora", "same-tokens"); len(got) != 0 { + t.Errorf("unscoped eviction must stay conservative and sweep every partition, got %+v", got) + } +} + +// ALL_CLEARED is a whole-replica cache flush, so it stays adapter-agnostic: the +// engine dropped every adapter's blocks at once. +func TestAdapterPartitionAllClearedIgnoresAdapter(t *testing.T) { + idx := New() + ingestUnder(idx, "replica-0", "sql-lora", "p") + ingestUnder(idx, "replica-0", "chat-lora", "p") + + idx.ApplyEvent(Event{ + Type: EventAllCleared, ReplicaID: "replica-0", + Model: adapterModel, Tenant: adapterTenant, + }) + + if got := lookupUnder(idx, "sql-lora", "p"); len(got) != 0 { + t.Errorf("sql-lora entry survived ALL_CLEARED: %+v", got) + } + if got := lookupUnder(idx, "chat-lora", "p"); len(got) != 0 { + t.Errorf("chat-lora entry survived ALL_CLEARED: %+v", got) + } +} + +// The diagnostic miss classifier is deliberately adapter-blind (scopeKey has no +// adapter — see its doc comment): an unseen adapter under a KNOWN +// (tenant, model, hash_scheme) is a novel-prefix miss (NO_HINT), not a +// contract-key mismatch. Pinning this keeps UNKNOWN_HASH_SCHEME meaning +// "wrong hash_scheme" rather than silently becoming "unseen adapter". +func TestAdapterPartitionMissClassifierUnchangedByAdapter(t *testing.T) { + idx := New() + ingestUnder(idx, "replica-0", "sql-lora", "p") + + res := idx.LookupRoute(LookupRequest{ + Tenant: adapterTenant, Model: adapterModel, HashScheme: adapterScheme, + Adapter: "never-seen-lora", PrefixHash: hash("p"), + }) + if res.Strategy != StrategyNone { + t.Errorf("strategy = %v, want StrategyNone (NO_HINT) for an unseen adapter in a known engine domain", res.Strategy) + } + + res = idx.LookupRoute(LookupRequest{ + Tenant: adapterTenant, Model: adapterModel, HashScheme: "sglang", + Adapter: "sql-lora", PrefixHash: hash("p"), + }) + if res.Strategy != StrategyUnknownHashScheme { + t.Errorf("strategy = %v, want StrategyUnknownHashScheme — the scheme diagnostic must be unaffected by adapters", res.Strategy) + } +} diff --git a/pkg/index/index.go b/pkg/index/index.go index d2f1180e..d1279b0c 100644 --- a/pkg/index/index.go +++ b/pkg/index/index.go @@ -196,6 +196,13 @@ type PrefixRef struct { // path only ever reports T1 (the engine KV cache); tier detection is a // later ticket. A chain entry's Tier applies to every block it expands to. Tier CacheTier + // Adapter is the stable adapter (e.g. LoRA) identity whose KV this entry + // describes — the index partition the entry lands in. Per-entry because one + // replica can hold KV for several adapters at once. Empty falls back to + // Update.Adapter, and an empty result is the default partition: identical to + // the behavior before adapters existed. A chain entry's Adapter applies to + // every per-block entry it expands to. + Adapter string } // Update is the authoritative state a replica reports (from ReportCacheState). @@ -204,9 +211,14 @@ type Update struct { Model string Tenant string HashScheme string - Timestamp time.Time // zero → treated as "now" - Prefixes []PrefixRef - Stats *ReplicaStats + // Adapter is the default adapter partition for Prefixes that set no Adapter + // of their own — the convenient form for a producer whose replica serves a + // single adapter. Empty = the default partition (pre-adapter behavior). + // Stats are adapter-independent and are never partitioned by it. + Adapter string + Timestamp time.Time // zero → treated as "now" + Prefixes []PrefixRef + Stats *ReplicaStats } // EventType mirrors the proto CacheEvent.Type deltas. @@ -228,7 +240,14 @@ type Event struct { Model string Tenant string PrefixHash []byte - Timestamp time.Time + // Adapter narrows a PREFIX_EVICTED removal to one adapter partition. Empty + // removes the prefix from EVERY adapter partition — the conservative legacy + // behavior, and identical to the pre-adapter code for producers that never + // set it (all of whose entries live in the "" partition anyway). Ignored by + // ALL_CLEARED (a flush clears the replica across adapters) and by + // REPLICA_UPDATED (liveness is adapter-independent). + Adapter string + Timestamp time.Time } // LookupRequest asks which replicas hold a given prefix, within a hash scheme. @@ -242,9 +261,16 @@ type Event struct { // TTFTBudgetMs / TBTBudgetMs carry the caller's SLO targets (proto SLO message); // 0 means "no SLO hint" and the ranker treats the request as baseline-latency. type LookupRequest struct { - Model string - Tenant string - HashScheme string + Model string + Tenant string + HashScheme string + // Adapter selects the adapter partition to match in. Because the content + // fingerprint is token-only, a lookup MUST supply the same adapter identity + // the producer ingested under or it will not match — the same producer/ + // consumer agreement HashScheme already requires. Empty is the default + // partition, which is where every non-LoRA deployment both ingests and looks + // up, so single-adapter behavior is unchanged. + Adapter string PrefixHash []byte TokenCount int32 BlockHashes [][]byte @@ -422,10 +448,22 @@ func DefaultRankerConfig() RankerConfig { } } +// prefixKey is the index partition key. adapter joins (tenant, model, +// hashScheme) ahead of the content hash because the content fingerprint is +// derived from token IDs ALONE: two requests with identical tokens under +// different LoRA adapters produce the SAME prefixHash, and without the partition +// they would collide on one map entry — a lookup for adapter A could be handed a +// replica that only holds adapter B's KV for those tokens. Partitioning (rather +// than mixing the adapter into the hash) keeps the fingerprint construction and +// its golden vectors untouched, and keeps the empty-adapter case byte-identical +// to the pre-adapter key. +// +// scopeKey deliberately does NOT gain adapter — see its doc comment. type prefixKey struct { tenant string model string hashScheme string + adapter string // stable adapter identity ("" = default / no adapter) prefixHash string // raw engine bytes, used as an opaque map key } @@ -447,6 +485,20 @@ type modelKey struct { // scopeKey identifies a (tenant, model, hash_scheme) — the engine domain // granularity TENANT_HOT needs for its serving-membership check. +// +// Adapter is intentionally NOT part of this key, even though it IS part of +// prefixKey. scopeKey answers a REPLICA-membership question ("which replicas +// serve this engine domain?"), and the surfaces that read it — the +// distinguishing-power denominator, the TENANT_HOT serving check, the +// AFFINITY_HINT candidate set, and the UNKNOWN_HASH_SCHEME classifier — carry no +// per-adapter cache-content claim (TENANT_HOT and AFFINITY_HINT ship +// matched_tokens=0 by contract). Adapters are also loaded and unloaded under a +// live engine, so adapter residency is a property of individual KV entries, not +// of a replica's membership in an engine domain. The aliasing hazard is +// content-level, so the CONTENT key is what gets partitioned; keeping scopeKey +// adapter-free additionally leaves the diagnostic reason codes meaning exactly +// what they meant before (UNKNOWN_HASH_SCHEME still means "wrong hash_scheme", +// not "unseen adapter"). type scopeKey struct { tenant string model string @@ -725,6 +777,14 @@ func (i *Index) Ingest(u Update) { // engine KV cache). Resolved once per prefix so the chain's // per-block entries and the preserved legacy blob share one tier. tier := normalizeIngestTier(p.Tier) + // Adapter partition for this entry: its own, else the update-level + // default, else "" (the default partition — pre-adapter behavior). + // Resolved once per prefix so a chain's per-block entries and the + // preserved legacy blob all land in the same partition. + adapter := p.Adapter + if adapter == "" { + adapter = u.Adapter + } // Chain form: expand into one per-block entry per hash, keyed by // the block hash with cumulative tokenCount so a legacy exact-match // against any single block hash still works. The parallel arrays @@ -742,7 +802,7 @@ func (i *Index) Ingest(u Update) { var cumulative int32 for j, h := range p.BlockHashes { cumulative += p.BlockTokenCounts[j] - i.upsertReplicaLocked(prefixKey{u.Tenant, u.Model, u.HashScheme, string(h)}, u.ReplicaID, cumulative, tier, ts) + i.upsertReplicaLocked(prefixKey{u.Tenant, u.Model, u.HashScheme, adapter, string(h)}, u.ReplicaID, cumulative, tier, ts) } // Preserve the legacy single-blob key too when the producer // set both representations on the same entry — so legacy @@ -750,14 +810,14 @@ func (i *Index) Ingest(u Update) { // via exact-match on PrefixHash. The chain path otherwise // silently breaks backward-compat for unmigrated clients. if len(p.PrefixHash) > 0 { - i.upsertReplicaLocked(prefixKey{u.Tenant, u.Model, u.HashScheme, string(p.PrefixHash)}, u.ReplicaID, p.TokenCount, tier, ts) + i.upsertReplicaLocked(prefixKey{u.Tenant, u.Model, u.HashScheme, adapter, string(p.PrefixHash)}, u.ReplicaID, p.TokenCount, tier, ts) } continue } // Legacy single-blob exact-match entry. The helper does the // totalEntries + scope bookkeeping that main's inline form did, // so the chain and legacy paths agree on accounting. - i.upsertReplicaLocked(prefixKey{u.Tenant, u.Model, u.HashScheme, string(p.PrefixHash)}, u.ReplicaID, p.TokenCount, tier, ts) + i.upsertReplicaLocked(prefixKey{u.Tenant, u.Model, u.HashScheme, adapter, string(p.PrefixHash)}, u.ReplicaID, p.TokenCount, tier, ts) } } if u.Stats != nil { @@ -829,10 +889,21 @@ func (i *Index) ApplyEvent(ev Event) { case EventPrefixEvicted: // Remove the replica from the prefix across schemes — removal is // conservative, so matching opaque bytes without a scheme is safe. + // + // Adapter, unlike scheme, IS narrowed when the producer supplies it. The + // fingerprint is token-only, so one prefix hash can be live under several + // adapters at once on the same replica; dropping every partition for one + // adapter's GPU eviction would throw away hints that are still valid. An + // empty ev.Adapter keeps the original cross-partition sweep, which is + // exactly what a pre-adapter producer gets (all its entries are in the "" + // partition anyway). for key, replicas := range i.prefixes { if key.tenant != ev.Tenant || key.model != ev.Model || key.prefixHash != hash { continue } + if ev.Adapter != "" && key.adapter != ev.Adapter { + continue + } i.removeReplicaLocked(key, replicas, ev.ReplicaID) } case EventAllCleared: @@ -923,7 +994,7 @@ func (i *Index) lookupWithHits(req LookupRequest) ([]ReplicaScore, map[string][] // inspect reason_code // continue to fail open on a downgrade. func (i *Index) lookupExact(req LookupRequest) ([]ReplicaScore, map[string][]*replicaEntry) { - key := prefixKey{req.Tenant, req.Model, req.HashScheme, string(req.PrefixHash)} + key := prefixKey{req.Tenant, req.Model, req.HashScheme, req.Adapter, string(req.PrefixHash)} now := i.now() sloBiasFactor := i.sloTightBiasCoefficient(req.TTFTBudgetMs) @@ -1097,7 +1168,7 @@ func (i *Index) lookupChain(req LookupRequest) ([]ReplicaScore, map[string][]*re current := map[string]running{} finalized := map[string]running{} for blockIdx, h := range req.BlockHashes { - key := prefixKey{req.Tenant, req.Model, req.HashScheme, string(h)} + key := prefixKey{req.Tenant, req.Model, req.HashScheme, req.Adapter, string(h)} holders := i.prefixes[key] blockTokens := req.BlockTokenCounts[blockIdx] if blockIdx == 0 { @@ -2390,6 +2461,14 @@ func (i *Index) enforceCapLocked() map[string]int { if x.key.hashScheme != y.key.hashScheme { return x.key.hashScheme < y.key.hashScheme } + // The adapter partition is part of the key, so it must be part of + // the tie-break too: two adapters can hold the SAME prefixHash + // (the fingerprint is token-only), and without this the comparator + // would report "equal" for two distinct keys and reintroduce + // map-iteration-order dependence in the victim set. + if x.key.adapter != y.key.adapter { + return x.key.adapter < y.key.adapter + } return x.key.prefixHash < y.key.prefixHash } return x.replica < y.replica @@ -2471,7 +2550,14 @@ func (i *Index) evictOldestForTenantLocked(tenant string, maxPrefixes int64) int if !all[a].age.Equal(all[b].age) { return all[a].age.Before(all[b].age) } - return all[a].key.prefixHash < all[b].key.prefixHash + if all[a].key.prefixHash != all[b].key.prefixHash { + return all[a].key.prefixHash < all[b].key.prefixHash + } + // Same tenant + same prefix hash under two different adapters is a + // genuine pair of distinct keys — the fingerprint is token-only — so the + // adapter has to break the tie or the victim order would depend on map + // iteration order. + return all[a].key.adapter < all[b].key.adapter }) removed := 0 for _, r := range all { diff --git a/pkg/index/lfu_eviction_test.go b/pkg/index/lfu_eviction_test.go index 299eb664..4e2f2f0d 100644 --- a/pkg/index/lfu_eviction_test.go +++ b/pkg/index/lfu_eviction_test.go @@ -26,7 +26,7 @@ func accessCountOf(t *testing.T, idx *Index, tenant, model, scheme, prefix, repl t.Helper() idx.mu.RLock() defer idx.mu.RUnlock() - e := idx.prefixes[prefixKey{tenant, model, scheme, prefix}][replica] + e := idx.prefixes[prefixKey{tenant, model, scheme, "", prefix}][replica] if e == nil { t.Fatalf("no entry for prefix %q replica %q", prefix, replica) } @@ -39,7 +39,7 @@ func setAccessCount(t *testing.T, idx *Index, tenant, model, scheme, prefix, rep t.Helper() idx.mu.Lock() defer idx.mu.Unlock() - e := idx.prefixes[prefixKey{tenant, model, scheme, prefix}][replica] + e := idx.prefixes[prefixKey{tenant, model, scheme, "", prefix}][replica] if e == nil { t.Fatalf("no entry for prefix %q replica %q", prefix, replica) } diff --git a/pkg/server/adapter_partition_test.go b/pkg/server/adapter_partition_test.go new file mode 100644 index 00000000..15c83c23 --- /dev/null +++ b/pkg/server/adapter_partition_test.go @@ -0,0 +1,125 @@ +package server + +import ( + "context" + "testing" + + "github.com/cachebox-project/inference-cache/pkg/adapters/engine" + "github.com/cachebox-project/inference-cache/pkg/fingerprint" + icpb "github.com/cachebox-project/inference-cache/pkg/server/proto/inferencecache/v1alpha1" +) + +// End-to-end round trip of adapter identity: engine KV event (carrying vLLM's +// lora_id) -> subscriber -> ReportCacheState -> index partition -> LookupRoute. +// +// The scenario is the one SMG v1.8.0's runtime LoRA load/unload makes ordinary: +// one replica caches the SAME token content under two adapters. The content +// fingerprint is token-only, so both blocks carry the identical prefix hash — +// only the partition keeps a lookup for one adapter from being handed the +// other's KV. +func TestLookupRouteAdapterPartitionRoundTrip(t *testing.T) { + const ( + blockTok = 128 + modelID = "vllm-model" + tenantID = "ic-smoke" + scheme = "vllm" + ) + sqlID, chatID := int64(1), int64(2) + + // Identical tokens under two different adapters — the aliasing input. + toks := tokenSeq(1_000, blockTok) + key := fingerprint.Bytes(fingerprint.PrefixHashes(toks, blockTok)[0]) + + cfg := engine.Config{ + ReplicaID: "vllm-engine-cs1", + ModelID: modelID, + TenantID: tenantID, + HashScheme: scheme, + // Map the engine's load-order integer ids onto the stable adapter names + // the gateway sends as adapter_id. + AdapterNames: map[int64]string{sqlID: "sql-lora", chatID: "chat-lora"}, + } + client, stop := runEngineReporterCfgAgainstServer(t, cfg, + []engine.ReporterOption{engine.WithIgnoreBlockRemoved(true)}, + &engine.EventBatch{Events: []engine.Event{ + engine.BlockStored{BlockHashes: [][]byte{be8(1)}, TokenIDs: toks, BlockSize: blockTok, LoRAID: &sqlID}, + engine.BlockStored{BlockHashes: [][]byte{be8(2)}, TokenIDs: toks, BlockSize: blockTok, LoRAID: &chatID}, + }}, + ) + defer stop() + + lookup := func(adapter string) *icpb.LookupRouteResponse { + t.Helper() + resp, err := client.LookupRoute(context.Background(), &icpb.LookupRouteRequest{ + ModelId: modelID, TenantId: tenantID, HashScheme: scheme, + AdapterId: adapter, + BlockHashes: [][]byte{key}, + BlockTokenCounts: []int32{blockTok}, + }) + if err != nil { + t.Fatalf("LookupRoute(adapter=%q): %v", adapter, err) + } + return resp + } + + // Same tokens + same adapter still hits — partitioning is not a hash change. + sql := lookup("sql-lora") + if sql.GetReasonCode() != "PREFIX_MATCH" { + t.Fatalf("sql-lora reason_code = %s, want PREFIX_MATCH", sql.GetReasonCode()) + } + if sql.GetAdapterId() != "sql-lora" { + t.Errorf("response adapter_id = %q, want sql-lora (echo of the partition consulted)", sql.GetAdapterId()) + } + if chat := lookup("chat-lora"); chat.GetReasonCode() != "PREFIX_MATCH" { + t.Errorf("chat-lora reason_code = %s, want PREFIX_MATCH", chat.GetReasonCode()) + } + + // An adapter that never cached this content must NOT inherit the hint from + // the adapters that did — this is the alias the partition prevents. + if other := lookup("never-loaded-lora"); other.GetReasonCode() == "PREFIX_MATCH" { + t.Errorf("unrelated adapter got %s with scores %+v; identical tokens under a different adapter must not alias", + other.GetReasonCode(), other.GetReplicaScores()) + } + + // A gateway that sends no adapter_id looks in the default partition, where + // this LoRA-scoped content was never ingested. + if legacy := lookup(""); legacy.GetReasonCode() == "PREFIX_MATCH" { + t.Errorf("no-adapter lookup got %s; adapter-scoped ingest must not surface in the default partition", legacy.GetReasonCode()) + } +} + +// The non-LoRA deployment — the overwhelming majority — must be untouched: the +// engine emits no lora_id, the subscriber reports the default partition, and a +// gateway that has never heard of adapter_id still matches exactly as before. +func TestLookupRouteWithoutAdapterIsUnchanged(t *testing.T) { + const ( + blockTok = 128 + modelID = "vllm-model" + tenantID = "ic-smoke" + scheme = "vllm" + ) + toks := tokenSeq(2_000, blockTok) + key := fingerprint.Bytes(fingerprint.PrefixHashes(toks, blockTok)[0]) + + client, stop := runEngineReporterAgainstServer(t, + []engine.ReporterOption{engine.WithIgnoreBlockRemoved(true)}, + &engine.EventBatch{Events: []engine.Event{ + engine.BlockStored{BlockHashes: [][]byte{be8(1)}, TokenIDs: toks, BlockSize: blockTok}, + }}, + ) + defer stop() + + resp, err := client.LookupRoute(context.Background(), &icpb.LookupRouteRequest{ + ModelId: modelID, TenantId: tenantID, HashScheme: scheme, + BlockHashes: [][]byte{key}, BlockTokenCounts: []int32{blockTok}, + }) + if err != nil { + t.Fatalf("LookupRoute: %v", err) + } + if resp.GetReasonCode() != "PREFIX_MATCH" { + t.Fatalf("reason_code = %s, want PREFIX_MATCH — adding the adapter partition must not regress non-LoRA deployments", resp.GetReasonCode()) + } + if resp.GetAdapterId() != "" { + t.Errorf("response adapter_id = %q, want empty for a request that set none", resp.GetAdapterId()) + } +} diff --git a/pkg/server/inferencecache_service.go b/pkg/server/inferencecache_service.go index 12e11f97..d6f645ec 100644 --- a/pkg/server/inferencecache_service.go +++ b/pkg/server/inferencecache_service.go @@ -293,9 +293,13 @@ func (s *inferenceCacheService) LookupRoute(ctx context.Context, req *icpb.Looku slo := req.GetSlo() lookupReq := index.LookupRequest{ - Model: model, - Tenant: tenant, - HashScheme: req.GetHashScheme(), + Model: model, + Tenant: tenant, + HashScheme: req.GetHashScheme(), + // Adapter partition to match in. The content fingerprint is token-only, + // so this is what keeps two adapters' identical token content in disjoint + // keyspaces. Empty = the default partition (unchanged legacy behavior). + Adapter: req.GetAdapterId(), PrefixHash: in.exactPrefixHash, TokenCount: in.exactTokenCount, BlockHashes: lookupBlockHashes, @@ -613,6 +617,9 @@ func (s *inferenceCacheService) buildLookupResponse(req *icpb.LookupRouteRequest // the same tokens this lookup was fingerprinted over. Empty on the // token_ids / pre-fingerprinted paths (the caller already has the tokens). resp.TokenIds = in.echoTokens + // Echo the adapter partition the index was consulted in, so a caller can + // confirm which partition answered without correlating back to its request. + resp.AdapterId = req.GetAdapterId() resp.LookupLatencyUs = elapsed.Microseconds() s.metrics.observeLookup(model, resp.ReasonCode, len(result.Scores) > 0, elapsed) return resp @@ -667,6 +674,9 @@ func (s *inferenceCacheService) tryAffinityResponse(req *icpb.LookupRouteRequest // when the routing hint is an affinity pick. Empty on the block_hashes / // token_ids paths (the caller already holds the tokens). TokenIds: in.echoTokens, + // Same partition echo as buildLookupResponse — the affinity pick was + // made after the index was consulted in this adapter's partition. + AdapterId: req.GetAdapterId(), } s.metrics.observeLookup(model, resp.ReasonCode, true, elapsed) return resp @@ -1023,7 +1033,10 @@ func (s *inferenceCacheService) PublishEvent(_ context.Context, ev *icpb.CacheEv Model: ev.GetModelId(), Tenant: ev.GetTenantId(), PrefixHash: ev.GetPrefixHash(), - Timestamp: microsToTime(ev.GetTimestampUs()), + // Narrows a PREFIX_EVICTED to one adapter partition; empty keeps the + // conservative cross-partition removal (legacy producers). + Adapter: ev.GetAdapterId(), + Timestamp: microsToTime(ev.GetTimestampUs()), }) } return &icpb.Ack{Accepted: true}, nil @@ -1045,7 +1058,12 @@ func updateFromProto(u *icpb.CacheStateUpdate) index.Update { Model: u.GetModelId(), Tenant: u.GetTenantId(), HashScheme: u.GetHashScheme(), - Timestamp: microsToTime(u.GetTimestampUs()), + // Update-level adapter is only the DEFAULT for entries that set none — + // the index applies the per-entry override. A multi-adapter producer + // (the kvevent subscriber, which batches events across adapters into one + // update) stamps each entry instead and leaves this empty. + Adapter: u.GetAdapterId(), + Timestamp: microsToTime(u.GetTimestampUs()), } for _, p := range u.GetPrefixes() { out.Prefixes = append(out.Prefixes, index.PrefixRef{ @@ -1053,6 +1071,7 @@ func updateFromProto(u *icpb.CacheStateUpdate) index.Update { TokenCount: p.GetTokenCount(), BlockHashes: p.GetBlockHashes(), BlockTokenCounts: p.GetBlockTokenCounts(), + Adapter: p.GetAdapterId(), }) } if st := u.GetStats(); st != nil { diff --git a/pkg/server/lmcache_offload_integration_test.go b/pkg/server/lmcache_offload_integration_test.go index b64ab6d7..2cc65a5e 100644 --- a/pkg/server/lmcache_offload_integration_test.go +++ b/pkg/server/lmcache_offload_integration_test.go @@ -32,15 +32,22 @@ import ( // canonical L2 offload shape. func runEngineReporterAgainstServer(t *testing.T, opts []engine.ReporterOption, batches ...*engine.EventBatch) (client icpb.InferenceCacheClient, stop func()) { t.Helper() - conn, _, stopServer := startInProcessServerConn(t) - client = icpb.NewInferenceCacheClient(conn) - - cfg := engine.Config{ + return runEngineReporterCfgAgainstServer(t, engine.Config{ ReplicaID: "vllm-engine-cs1", ModelID: "vllm-model", TenantID: "ic-smoke", HashScheme: "vllm", - } + }, opts, batches...) +} + +// runEngineReporterCfgAgainstServer is runEngineReporterAgainstServer with an +// explicit subscriber Config, so a test can vary the engine-side identity (e.g. +// supply Config.AdapterNames to exercise LoRA index partitioning end to end). +func runEngineReporterCfgAgainstServer(t *testing.T, cfg engine.Config, opts []engine.ReporterOption, batches ...*engine.EventBatch) (client icpb.InferenceCacheClient, stop func()) { + t.Helper() + conn, _, stopServer := startInProcessServerConn(t) + client = icpb.NewInferenceCacheClient(conn) + // Short flush window so the Run loop drains promptly when the input closes. opts = append([]engine.ReporterOption{engine.WithWindow(10 * time.Millisecond)}, opts...) reporter := engine.NewReporter(client, cfg, opts...) diff --git a/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go b/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go index ca74ffa1..bebacc2a 100644 --- a/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go +++ b/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go @@ -365,8 +365,23 @@ type LookupRouteRequest struct { // (model, text) lookup matches the engine's cached prefix by construction. // hash_scheme / model_id / tenant_id are still supplied by the caller (engine // routing config, not tokenizer knowledge). - TokenIds []uint32 `protobuf:"varint,9,rep,packed,name=token_ids,json=tokenIds,proto3" json:"token_ids,omitempty"` - PromptText string `protobuf:"bytes,10,opt,name=prompt_text,json=promptText,proto3" json:"prompt_text,omitempty"` + TokenIds []uint32 `protobuf:"varint,9,rep,packed,name=token_ids,json=tokenIds,proto3" json:"token_ids,omitempty"` + PromptText string `protobuf:"bytes,10,opt,name=prompt_text,json=promptText,proto3" json:"prompt_text,omitempty"` + // Adapter (e.g. LoRA) the request targets. The content fingerprint is derived + // from token IDs ALONE, so two requests with identical tokens under different + // adapters produce the SAME prefix_hash / block_hashes. adapter_id partitions + // the INDEX so those identical keys cannot alias: a lookup only ever matches + // entries ingested under the same adapter_id. It is NOT mixed into the hash — + // the fingerprint construction and its golden vectors are unchanged. + // + // Empty/unset is the default partition and is exactly the behavior before this + // field existed: a single-adapter (or LoRA-free) deployment ingests and looks + // up under "" and is unaffected. A lookup that sets adapter_id will NOT match + // entries ingested without one (and vice versa) — producer and consumer must + // agree on the identifier, the same rule hash_scheme already follows. The + // server treats the value as an opaque string and never interprets it. + // Additive, v1alpha1-compatible. + AdapterId string `protobuf:"bytes,11,opt,name=adapter_id,json=adapterId,proto3" json:"adapter_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -471,6 +486,13 @@ func (x *LookupRouteRequest) GetPromptText() string { return "" } +func (x *LookupRouteRequest) GetAdapterId() string { + if x != nil { + return x.AdapterId + } + return "" +} + type ReplicaScore struct { state protoimpl.MessageState `protogen:"open.v1"` ReplicaId string `protobuf:"bytes,1,opt,name=replica_id,json=replicaId,proto3" json:"replica_id,omitempty"` @@ -565,7 +587,15 @@ type LookupRouteResponse struct { // token array on every lookup). Forward these exact tokens to the engine // (e.g. /v1/completions with a token-ID prompt) so the engine caches the same // tokens the fingerprint was computed over — match by construction. - TokenIds []uint32 `protobuf:"varint,4,rep,packed,name=token_ids,json=tokenIds,proto3" json:"token_ids,omitempty"` + TokenIds []uint32 `protobuf:"varint,4,rep,packed,name=token_ids,json=tokenIds,proto3" json:"token_ids,omitempty"` + // Echo of the adapter partition the index was consulted in — the request's + // adapter_id. Lets a caller confirm end-to-end which partition answered + // without correlating back to its own request. Empty on the default + // (no-adapter) partition, which is what every pre-adapter client sees, and + // also empty on the fail-open envelopes returned BEFORE the index is + // consulted (TIMEOUT, tokenizer fail-open, POLICY_REQUIRES_CHAIN), where no + // partition was resolved. Additive, v1alpha1-compatible. + AdapterId string `protobuf:"bytes,5,opt,name=adapter_id,json=adapterId,proto3" json:"adapter_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -628,6 +658,13 @@ func (x *LookupRouteResponse) GetTokenIds() []uint32 { return nil } +func (x *LookupRouteResponse) GetAdapterId() string { + if x != nil { + return x.AdapterId + } + return "" +} + type LookupPDRouteRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ModelId string `protobuf:"bytes,1,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` @@ -785,8 +822,15 @@ type PrefixEntry struct { // for exact-match indexing (legacy path). BlockHashes [][]byte `protobuf:"bytes,3,rep,name=block_hashes,json=blockHashes,proto3" json:"block_hashes,omitempty"` BlockTokenCounts []int32 `protobuf:"varint,4,rep,packed,name=block_token_counts,json=blockTokenCounts,proto3" json:"block_token_counts,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Adapter (e.g. LoRA) whose KV this entry describes. Per-ENTRY rather than + // per-update because one engine replica can hold KV for several adapters at + // once — vLLM's BlockStored carries a per-event lora_id, and a subscriber + // batches events across adapters into a single CacheStateUpdate. Empty falls + // back to CacheStateUpdate.adapter_id, and an empty result is the default + // partition (legacy behavior). Additive, v1alpha1-compatible. + AdapterId string `protobuf:"bytes,5,opt,name=adapter_id,json=adapterId,proto3" json:"adapter_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PrefixEntry) Reset() { @@ -847,6 +891,13 @@ func (x *PrefixEntry) GetBlockTokenCounts() []int32 { return nil } +func (x *PrefixEntry) GetAdapterId() string { + if x != nil { + return x.AdapterId + } + return "" +} + type ReplicaStats struct { state protoimpl.MessageState `protogen:"open.v1"` ReplicaId string `protobuf:"bytes,1,opt,name=replica_id,json=replicaId,proto3" json:"replica_id,omitempty"` @@ -960,14 +1011,21 @@ func (x *ReplicaStats) GetT2QueryTokens() int64 { } type CacheStateUpdate struct { - state protoimpl.MessageState `protogen:"open.v1"` - ReplicaId string `protobuf:"bytes,1,opt,name=replica_id,json=replicaId,proto3" json:"replica_id,omitempty"` - ModelId string `protobuf:"bytes,2,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` - TenantId string `protobuf:"bytes,3,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` - HashScheme string `protobuf:"bytes,4,opt,name=hash_scheme,json=hashScheme,proto3" json:"hash_scheme,omitempty"` // engine-defined - TimestampUs int64 `protobuf:"varint,5,opt,name=timestamp_us,json=timestampUs,proto3" json:"timestamp_us,omitempty"` - Prefixes []*PrefixEntry `protobuf:"bytes,6,rep,name=prefixes,proto3" json:"prefixes,omitempty"` - Stats *ReplicaStats `protobuf:"bytes,7,opt,name=stats,proto3" json:"stats,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ReplicaId string `protobuf:"bytes,1,opt,name=replica_id,json=replicaId,proto3" json:"replica_id,omitempty"` + ModelId string `protobuf:"bytes,2,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` + TenantId string `protobuf:"bytes,3,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + HashScheme string `protobuf:"bytes,4,opt,name=hash_scheme,json=hashScheme,proto3" json:"hash_scheme,omitempty"` // engine-defined + TimestampUs int64 `protobuf:"varint,5,opt,name=timestamp_us,json=timestampUs,proto3" json:"timestamp_us,omitempty"` + Prefixes []*PrefixEntry `protobuf:"bytes,6,rep,name=prefixes,proto3" json:"prefixes,omitempty"` + Stats *ReplicaStats `protobuf:"bytes,7,opt,name=stats,proto3" json:"stats,omitempty"` + // Default adapter partition for every PrefixEntry in this update that does not + // set its own adapter_id. Convenience for a producer whose replica serves a + // single adapter; a multi-adapter producer sets it per entry instead. Empty = + // the default partition (legacy behavior). Stats are adapter-independent (one + // ReplicaStats per replica) and are NOT partitioned by this field. Additive, + // v1alpha1-compatible. + AdapterId string `protobuf:"bytes,8,opt,name=adapter_id,json=adapterId,proto3" json:"adapter_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1051,6 +1109,13 @@ func (x *CacheStateUpdate) GetStats() *ReplicaStats { return nil } +func (x *CacheStateUpdate) GetAdapterId() string { + if x != nil { + return x.AdapterId + } + return "" +} + type CacheSummary struct { state protoimpl.MessageState `protogen:"open.v1"` TotalPrefixes int64 `protobuf:"varint,1,opt,name=total_prefixes,json=totalPrefixes,proto3" json:"total_prefixes,omitempty"` @@ -1208,13 +1273,22 @@ func (x *GetCacheStateResponse) GetSummary() *CacheSummary { } type CacheEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - Type CacheEvent_Type `protobuf:"varint,1,opt,name=type,proto3,enum=inferencecache.v1alpha1.CacheEvent_Type" json:"type,omitempty"` - ReplicaId string `protobuf:"bytes,2,opt,name=replica_id,json=replicaId,proto3" json:"replica_id,omitempty"` - ModelId string `protobuf:"bytes,3,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` - TenantId string `protobuf:"bytes,4,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` - PrefixHash []byte `protobuf:"bytes,5,opt,name=prefix_hash,json=prefixHash,proto3" json:"prefix_hash,omitempty"` - TimestampUs int64 `protobuf:"varint,6,opt,name=timestamp_us,json=timestampUs,proto3" json:"timestamp_us,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Type CacheEvent_Type `protobuf:"varint,1,opt,name=type,proto3,enum=inferencecache.v1alpha1.CacheEvent_Type" json:"type,omitempty"` + ReplicaId string `protobuf:"bytes,2,opt,name=replica_id,json=replicaId,proto3" json:"replica_id,omitempty"` + ModelId string `protobuf:"bytes,3,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` + TenantId string `protobuf:"bytes,4,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + PrefixHash []byte `protobuf:"bytes,5,opt,name=prefix_hash,json=prefixHash,proto3" json:"prefix_hash,omitempty"` + TimestampUs int64 `protobuf:"varint,6,opt,name=timestamp_us,json=timestampUs,proto3" json:"timestamp_us,omitempty"` + // Adapter partition a PREFIX_EVICTED removal targets. Set, the server drops + // the prefix only from that partition; empty, it drops the prefix across + // EVERY adapter partition — the conservative legacy behavior (removal is + // soft state: an over-eager drop costs a cache miss, never a wrong answer), + // and identical to today for producers that never populate the field. + // Ignored by ALL_CLEARED (a cache flush clears the replica across every + // adapter) and by REPLICA_UPDATED (liveness is adapter-independent). + // Additive, v1alpha1-compatible. + AdapterId string `protobuf:"bytes,7,opt,name=adapter_id,json=adapterId,proto3" json:"adapter_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1291,6 +1365,13 @@ func (x *CacheEvent) GetTimestampUs() int64 { return 0 } +func (x *CacheEvent) GetAdapterId() string { + if x != nil { + return x.AdapterId + } + return "" +} + type StreamEventsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ModelId string `protobuf:"bytes,1,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` @@ -1563,8 +1644,8 @@ var file_inferencecache_v1alpha1_inferencecache_proto_rawDesc = string([]byte{ 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x35, 0x0a, 0x03, 0x53, 0x4c, 0x4f, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x74, 0x66, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x74, 0x74, 0x66, 0x74, 0x4d, 0x73, 0x12, 0x15, 0x0a, 0x06, 0x74, 0x62, 0x74, 0x5f, 0x6d, - 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x74, 0x62, 0x74, 0x4d, 0x73, 0x22, 0xfb, - 0x02, 0x0a, 0x12, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x74, 0x62, 0x74, 0x4d, 0x73, 0x22, 0x9a, + 0x03, 0x0a, 0x12, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, @@ -1587,243 +1668,253 @@ var file_inferencecache_v1alpha1_inferencecache_proto_rawDesc = string([]byte{ 0x0a, 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x08, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x49, 0x64, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x5f, 0x74, 0x65, 0x78, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x54, 0x65, 0x78, 0x74, 0x22, 0xdb, 0x01, 0x0a, - 0x0c, 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x0a, - 0x0a, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, - 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, 0x6f, - 0x72, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x6d, 0x61, 0x74, 0x63, - 0x68, 0x65, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x65, 0x73, 0x74, - 0x69, 0x6d, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x68, 0x69, 0x74, - 0x5f, 0x70, 0x72, 0x6f, 0x62, 0x18, 0x04, 0x20, 0x01, 0x28, 0x02, 0x52, 0x15, 0x65, 0x73, 0x74, - 0x69, 0x6d, 0x61, 0x74, 0x65, 0x64, 0x43, 0x61, 0x63, 0x68, 0x65, 0x48, 0x69, 0x74, 0x50, 0x72, - 0x6f, 0x62, 0x12, 0x36, 0x0a, 0x04, 0x74, 0x69, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x22, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, - 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, 0x65, - 0x54, 0x69, 0x65, 0x72, 0x52, 0x04, 0x74, 0x69, 0x65, 0x72, 0x22, 0xcd, 0x01, 0x0a, 0x13, 0x4c, - 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x0e, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x73, 0x63, - 0x6f, 0x72, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x69, 0x6e, 0x66, - 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, - 0x70, 0x68, 0x61, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x63, 0x6f, 0x72, - 0x65, 0x52, 0x0d, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x73, - 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x43, 0x6f, 0x64, - 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x6c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x5f, 0x6c, 0x61, 0x74, 0x65, - 0x6e, 0x63, 0x79, 0x5f, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x6c, 0x6f, - 0x6f, 0x6b, 0x75, 0x70, 0x4c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x55, 0x73, 0x12, 0x1b, 0x0a, - 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0d, - 0x52, 0x08, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x49, 0x64, 0x73, 0x22, 0xc5, 0x01, 0x0a, 0x14, 0x4c, - 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x50, 0x44, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x1b, - 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x70, - 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x0a, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x48, 0x61, 0x73, 0x68, 0x12, 0x2c, 0x0a, 0x12, - 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x10, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x70, 0x64, - 0x5f, 0x74, 0x6f, 0x70, 0x6f, 0x6c, 0x6f, 0x67, 0x79, 0x5f, 0x72, 0x65, 0x66, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0d, 0x70, 0x64, 0x54, 0x6f, 0x70, 0x6f, 0x6c, 0x6f, 0x67, 0x79, 0x52, - 0x65, 0x66, 0x22, 0xb9, 0x01, 0x0a, 0x15, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x50, 0x44, 0x52, - 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x12, - 0x70, 0x72, 0x65, 0x66, 0x69, 0x6c, 0x6c, 0x5f, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, 0x72, 0x65, 0x66, 0x69, 0x6c, - 0x6c, 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x64, 0x65, - 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x70, - 0x6c, 0x69, 0x63, 0x61, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, - 0x6f, 0x72, 0x74, 0x5f, 0x68, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, - 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x48, 0x69, 0x6e, 0x74, 0x12, 0x1f, 0x0a, - 0x0b, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, 0x22, 0xa0, - 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1f, - 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x48, 0x61, 0x73, 0x68, 0x12, - 0x1f, 0x0a, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, - 0x12, 0x21, 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x65, 0x73, - 0x18, 0x03, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0b, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x48, 0x61, 0x73, - 0x68, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x05, 0x52, - 0x10, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, - 0x73, 0x22, 0x85, 0x02, 0x0a, 0x0c, 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x74, 0x61, - 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x49, - 0x64, 0x12, 0x2c, 0x0a, 0x12, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x6d, 0x65, 0x6d, 0x6f, 0x72, - 0x79, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x63, - 0x61, 0x63, 0x68, 0x65, 0x4d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, - 0x19, 0x0a, 0x08, 0x68, 0x69, 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x02, 0x52, 0x07, 0x68, 0x69, 0x74, 0x52, 0x61, 0x74, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, - 0x65, 0x73, 0x73, 0x75, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x02, 0x52, 0x08, 0x70, 0x72, - 0x65, 0x73, 0x73, 0x75, 0x72, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, - 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, - 0x0d, 0x74, 0x32, 0x5f, 0x68, 0x69, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x32, 0x48, 0x69, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x32, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x74, 0x32, 0x51, 0x75, - 0x65, 0x72, 0x79, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0xac, 0x02, 0x0a, 0x10, 0x43, 0x61, - 0x63, 0x68, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1d, - 0x0a, 0x0a, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x49, 0x64, 0x12, 0x19, 0x0a, - 0x08, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, - 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x6e, - 0x61, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x68, 0x61, 0x73, 0x68, 0x5f, 0x73, 0x63, - 0x68, 0x65, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x61, 0x73, 0x68, - 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x5f, 0x75, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x12, 0x40, 0x0a, 0x08, 0x70, 0x72, 0x65, - 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x69, 0x6e, - 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, - 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x08, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x05, 0x73, - 0x74, 0x61, 0x74, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x69, 0x6e, 0x66, - 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, - 0x70, 0x68, 0x61, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x74, 0x61, 0x74, - 0x73, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x22, 0x58, 0x0a, 0x0c, 0x43, 0x61, 0x63, 0x68, - 0x65, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x6f, 0x74, 0x61, - 0x6c, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x0d, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x12, - 0x21, 0x0a, 0x0c, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x68, 0x6f, 0x74, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, - 0x65, 0x73, 0x22, 0x4e, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x74, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x6f, - 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x6f, - 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, - 0x49, 0x64, 0x22, 0x9b, 0x01, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, - 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x08, - 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, - 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, - 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, - 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x08, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x73, 0x12, - 0x3f, 0x0a, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x25, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, - 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, 0x65, - 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, - 0x22, 0xcf, 0x02, 0x0a, 0x0a, 0x43, 0x61, 0x63, 0x68, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, - 0x3c, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, 0x2e, - 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, - 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, 0x65, 0x45, 0x76, 0x65, - 0x6e, 0x74, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, - 0x0a, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, - 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, - 0x74, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x6e, 0x61, - 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x68, - 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x70, 0x72, 0x65, 0x66, 0x69, - 0x78, 0x48, 0x61, 0x73, 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, - 0x6d, 0x70, 0x5f, 0x75, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x22, 0x68, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, - 0x12, 0x14, 0x0a, 0x10, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, - 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x50, 0x52, 0x45, 0x46, 0x49, 0x58, - 0x5f, 0x41, 0x44, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x50, 0x52, 0x45, 0x46, - 0x49, 0x58, 0x5f, 0x45, 0x56, 0x49, 0x43, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, - 0x52, 0x45, 0x50, 0x4c, 0x49, 0x43, 0x41, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, - 0x03, 0x12, 0x0f, 0x0a, 0x0b, 0x41, 0x4c, 0x4c, 0x5f, 0x43, 0x4c, 0x45, 0x41, 0x52, 0x45, 0x44, - 0x10, 0x04, 0x22, 0x8d, 0x01, 0x0a, 0x13, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x45, 0x76, 0x65, - 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x6f, - 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x6f, - 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, - 0x49, 0x64, 0x12, 0x3e, 0x0a, 0x05, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, - 0x0e, 0x32, 0x28, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, - 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, - 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x05, 0x74, 0x79, 0x70, - 0x65, 0x73, 0x22, 0x2c, 0x0a, 0x14, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x65, 0x74, 0x72, - 0x69, 0x63, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, - 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, - 0x22, 0xe9, 0x01, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, - 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, - 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4d, 0x65, - 0x74, 0x72, 0x69, 0x63, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x21, - 0x0a, 0x0c, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x5f, 0x75, 0x73, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, - 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, - 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x42, 0x0a, 0x03, - 0x41, 0x63, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, + 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x54, 0x65, 0x78, 0x74, 0x12, 0x1d, 0x0a, 0x0a, + 0x61, 0x64, 0x61, 0x70, 0x74, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x61, 0x64, 0x61, 0x70, 0x74, 0x65, 0x72, 0x49, 0x64, 0x22, 0xdb, 0x01, 0x0a, 0x0c, + 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a, + 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, + 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, + 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x6d, 0x61, 0x74, 0x63, 0x68, + 0x65, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x65, 0x73, 0x74, 0x69, + 0x6d, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x68, 0x69, 0x74, 0x5f, + 0x70, 0x72, 0x6f, 0x62, 0x18, 0x04, 0x20, 0x01, 0x28, 0x02, 0x52, 0x15, 0x65, 0x73, 0x74, 0x69, + 0x6d, 0x61, 0x74, 0x65, 0x64, 0x43, 0x61, 0x63, 0x68, 0x65, 0x48, 0x69, 0x74, 0x50, 0x72, 0x6f, + 0x62, 0x12, 0x36, 0x0a, 0x04, 0x74, 0x69, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x22, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, + 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, 0x65, 0x54, + 0x69, 0x65, 0x72, 0x52, 0x04, 0x74, 0x69, 0x65, 0x72, 0x22, 0xec, 0x01, 0x0a, 0x13, 0x4c, 0x6f, + 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x4c, 0x0a, 0x0e, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x73, 0x63, 0x6f, + 0x72, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x69, 0x6e, 0x66, 0x65, + 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, + 0x68, 0x61, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x63, 0x6f, 0x72, 0x65, + 0x52, 0x0d, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, - 0x2a, 0x60, 0x0a, 0x09, 0x43, 0x61, 0x63, 0x68, 0x65, 0x54, 0x69, 0x65, 0x72, 0x12, 0x1a, 0x0a, - 0x16, 0x43, 0x41, 0x43, 0x48, 0x45, 0x5f, 0x54, 0x49, 0x45, 0x52, 0x5f, 0x55, 0x4e, 0x53, 0x50, - 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x43, 0x41, 0x43, - 0x48, 0x45, 0x5f, 0x54, 0x49, 0x45, 0x52, 0x5f, 0x54, 0x31, 0x10, 0x01, 0x12, 0x11, 0x0a, 0x0d, - 0x43, 0x41, 0x43, 0x48, 0x45, 0x5f, 0x54, 0x49, 0x45, 0x52, 0x5f, 0x54, 0x32, 0x10, 0x02, 0x12, - 0x11, 0x0a, 0x0d, 0x43, 0x41, 0x43, 0x48, 0x45, 0x5f, 0x54, 0x49, 0x45, 0x52, 0x5f, 0x54, 0x33, - 0x10, 0x03, 0x32, 0xcc, 0x06, 0x0a, 0x0e, 0x49, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, - 0x43, 0x61, 0x63, 0x68, 0x65, 0x12, 0x71, 0x0a, 0x0e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x54, - 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x2e, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, - 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, - 0x31, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, - 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, - 0x31, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x0b, 0x4c, 0x6f, 0x6f, 0x6b, - 0x75, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x2b, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, + 0x12, 0x2a, 0x0a, 0x11, 0x6c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x5f, 0x6c, 0x61, 0x74, 0x65, 0x6e, + 0x63, 0x79, 0x5f, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x6c, 0x6f, 0x6f, + 0x6b, 0x75, 0x70, 0x4c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x55, 0x73, 0x12, 0x1b, 0x0a, 0x09, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0d, 0x52, + 0x08, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x49, 0x64, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x61, + 0x70, 0x74, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, + 0x64, 0x61, 0x70, 0x74, 0x65, 0x72, 0x49, 0x64, 0x22, 0xc5, 0x01, 0x0a, 0x14, 0x4c, 0x6f, 0x6f, + 0x6b, 0x75, 0x70, 0x50, 0x44, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, + 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x65, + 0x66, 0x69, 0x78, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, + 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x48, 0x61, 0x73, 0x68, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x72, + 0x65, 0x66, 0x69, 0x78, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x10, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x70, 0x64, 0x5f, 0x74, + 0x6f, 0x70, 0x6f, 0x6c, 0x6f, 0x67, 0x79, 0x5f, 0x72, 0x65, 0x66, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x70, 0x64, 0x54, 0x6f, 0x70, 0x6f, 0x6c, 0x6f, 0x67, 0x79, 0x52, 0x65, 0x66, + 0x22, 0xb9, 0x01, 0x0a, 0x15, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x50, 0x44, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x72, + 0x65, 0x66, 0x69, 0x6c, 0x6c, 0x5f, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, 0x72, 0x65, 0x66, 0x69, 0x6c, 0x6c, 0x52, + 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x64, 0x65, 0x63, 0x6f, + 0x64, 0x65, 0x5f, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0f, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x69, + 0x63, 0x61, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, + 0x74, 0x5f, 0x68, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x72, + 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x48, 0x69, 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x72, + 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0a, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, 0x22, 0xbf, 0x01, 0x0a, + 0x0b, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1f, 0x0a, 0x0b, + 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x0a, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x48, 0x61, 0x73, 0x68, 0x12, 0x1f, 0x0a, + 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x05, 0x52, 0x0a, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x21, + 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x65, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0b, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x48, 0x61, 0x73, 0x68, 0x65, + 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x05, 0x52, 0x10, 0x62, + 0x6c, 0x6f, 0x63, 0x6b, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x12, + 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x61, 0x70, 0x74, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x64, 0x61, 0x70, 0x74, 0x65, 0x72, 0x49, 0x64, 0x22, 0x85, + 0x02, 0x0a, 0x0c, 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, + 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x49, 0x64, 0x12, 0x2c, + 0x0a, 0x12, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x5f, 0x62, + 0x79, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x63, 0x61, 0x63, 0x68, + 0x65, 0x4d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x19, 0x0a, 0x08, + 0x68, 0x69, 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x52, 0x07, + 0x68, 0x69, 0x74, 0x52, 0x61, 0x74, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x73, 0x73, + 0x75, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x02, 0x52, 0x08, 0x70, 0x72, 0x65, 0x73, 0x73, + 0x75, 0x72, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x76, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0d, 0x74, 0x32, + 0x5f, 0x68, 0x69, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0b, 0x74, 0x32, 0x48, 0x69, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x26, + 0x0a, 0x0f, 0x74, 0x32, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x74, 0x32, 0x51, 0x75, 0x65, 0x72, 0x79, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0xcb, 0x02, 0x0a, 0x10, 0x43, 0x61, 0x63, 0x68, 0x65, + 0x53, 0x74, 0x61, 0x74, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, + 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x6f, + 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x6f, + 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, + 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, + 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x68, 0x61, 0x73, 0x68, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, + 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x61, 0x73, 0x68, 0x53, 0x63, 0x68, + 0x65, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x5f, 0x75, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x12, 0x40, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, + 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, + 0x61, 0x31, 0x2e, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, + 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, + 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, - 0x31, 0x2e, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, - 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, - 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x6e, 0x0a, 0x0d, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x50, 0x44, 0x52, 0x6f, - 0x75, 0x74, 0x65, 0x12, 0x2d, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, - 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x6f, - 0x6f, 0x6b, 0x75, 0x70, 0x50, 0x44, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, + 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x05, + 0x73, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x61, 0x70, 0x74, 0x65, 0x72, + 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x64, 0x61, 0x70, 0x74, + 0x65, 0x72, 0x49, 0x64, 0x22, 0x58, 0x0a, 0x0c, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x75, 0x6d, + 0x6d, 0x61, 0x72, 0x79, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x70, 0x72, + 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x74, 0x6f, + 0x74, 0x61, 0x6c, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x68, + 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0b, 0x68, 0x6f, 0x74, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x4e, + 0x0a, 0x14, 0x47, 0x65, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x49, + 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x49, 0x64, 0x22, 0x9b, + 0x01, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x08, 0x72, 0x65, 0x70, 0x6c, + 0x69, 0x63, 0x61, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x69, 0x6e, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x53, 0x74, 0x61, 0x74, + 0x73, 0x52, 0x08, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x73, 0x12, 0x3f, 0x0a, 0x07, 0x73, + 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x69, + 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, + 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x75, 0x6d, 0x6d, + 0x61, 0x72, 0x79, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0xee, 0x02, 0x0a, + 0x0a, 0x43, 0x61, 0x63, 0x68, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x3c, 0x0a, 0x04, 0x74, + 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, 0x2e, 0x69, 0x6e, 0x66, 0x65, + 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, + 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x54, + 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x70, + 0x6c, 0x69, 0x63, 0x61, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, + 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x6f, 0x64, 0x65, + 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x6f, 0x64, 0x65, + 0x6c, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x49, 0x64, + 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x48, 0x61, 0x73, + 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x5f, 0x75, + 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x55, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x61, 0x70, 0x74, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x64, 0x61, 0x70, 0x74, 0x65, + 0x72, 0x49, 0x64, 0x22, 0x68, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x10, 0x54, + 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, + 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x50, 0x52, 0x45, 0x46, 0x49, 0x58, 0x5f, 0x41, 0x44, 0x44, 0x45, + 0x44, 0x10, 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x50, 0x52, 0x45, 0x46, 0x49, 0x58, 0x5f, 0x45, 0x56, + 0x49, 0x43, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x52, 0x45, 0x50, 0x4c, 0x49, + 0x43, 0x41, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x0f, 0x0a, 0x0b, + 0x41, 0x4c, 0x4c, 0x5f, 0x43, 0x4c, 0x45, 0x41, 0x52, 0x45, 0x44, 0x10, 0x04, 0x22, 0x8d, 0x01, + 0x0a, 0x13, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x49, 0x64, + 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x3e, 0x0a, + 0x05, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x28, 0x2e, 0x69, + 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, + 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, 0x65, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x05, 0x74, 0x79, 0x70, 0x65, 0x73, 0x22, 0x2c, 0x0a, + 0x14, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0xe9, 0x01, 0x0a, 0x06, + 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x43, + 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, + 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x2e, + 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x01, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x5f, 0x75, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0b, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x1a, 0x39, 0x0a, 0x0b, + 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x42, 0x0a, 0x03, 0x41, 0x63, 0x6b, 0x12, 0x1a, + 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, + 0x61, 0x73, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0a, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, 0x2a, 0x60, 0x0a, 0x09, 0x43, + 0x61, 0x63, 0x68, 0x65, 0x54, 0x69, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x16, 0x43, 0x41, 0x43, 0x48, + 0x45, 0x5f, 0x54, 0x49, 0x45, 0x52, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, + 0x45, 0x44, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x43, 0x41, 0x43, 0x48, 0x45, 0x5f, 0x54, 0x49, + 0x45, 0x52, 0x5f, 0x54, 0x31, 0x10, 0x01, 0x12, 0x11, 0x0a, 0x0d, 0x43, 0x41, 0x43, 0x48, 0x45, + 0x5f, 0x54, 0x49, 0x45, 0x52, 0x5f, 0x54, 0x32, 0x10, 0x02, 0x12, 0x11, 0x0a, 0x0d, 0x43, 0x41, + 0x43, 0x48, 0x45, 0x5f, 0x54, 0x49, 0x45, 0x52, 0x5f, 0x54, 0x33, 0x10, 0x03, 0x32, 0xcc, 0x06, + 0x0a, 0x0e, 0x49, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x43, 0x61, 0x63, 0x68, 0x65, + 0x12, 0x71, 0x0a, 0x0e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, + 0x74, 0x65, 0x12, 0x2e, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x52, 0x65, 0x6e, + 0x64, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x52, 0x65, 0x6e, + 0x64, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x0b, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x12, 0x2b, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x6f, 0x6f, - 0x6b, 0x75, 0x70, 0x50, 0x44, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x6e, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x74, - 0x61, 0x74, 0x65, 0x12, 0x2d, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, - 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x47, 0x65, - 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, - 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x47, 0x65, 0x74, - 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x61, 0x63, 0x68, - 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, - 0x2e, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x1a, 0x1c, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, - 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x41, 0x63, 0x6b, 0x28, - 0x01, 0x12, 0x51, 0x0a, 0x0c, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x45, 0x76, 0x65, 0x6e, - 0x74, 0x12, 0x23, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, + 0x6b, 0x75, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x2c, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, + 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, + 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6e, 0x0a, + 0x0d, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x50, 0x44, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x2d, + 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x50, + 0x44, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, + 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, + 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x50, 0x44, + 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6e, 0x0a, + 0x0d, 0x47, 0x65, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x2d, + 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x61, 0x63, 0x68, + 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, + 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, + 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, + 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, + 0x10, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x53, 0x74, 0x61, 0x74, + 0x65, 0x12, 0x29, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, - 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x1a, 0x1c, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, - 0x2e, 0x41, 0x63, 0x6b, 0x12, 0x68, 0x0a, 0x11, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x43, 0x61, - 0x63, 0x68, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2c, 0x2e, 0x69, 0x6e, 0x66, 0x65, + 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x1a, 0x1c, 0x2e, 0x69, + 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, + 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x41, 0x63, 0x6b, 0x28, 0x01, 0x12, 0x51, 0x0a, 0x0c, + 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x23, 0x2e, 0x69, + 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, + 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, 0x65, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x1a, 0x1c, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, + 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x41, 0x63, 0x6b, 0x12, + 0x68, 0x0a, 0x11, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x43, 0x61, 0x63, 0x68, 0x65, 0x45, 0x76, + 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2c, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x53, + 0x74, 0x72, 0x65, 0x61, 0x6d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x61, 0x63, + 0x68, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x61, 0x0a, 0x0d, 0x53, 0x74, 0x72, + 0x65, 0x61, 0x6d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x12, 0x2d, 0x2e, 0x69, 0x6e, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x65, 0x74, 0x72, 0x69, + 0x63, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, - 0x68, 0x61, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, - 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, - 0x31, 0x2e, 0x43, 0x61, 0x63, 0x68, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x61, - 0x0a, 0x0d, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x12, - 0x2d, 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, - 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, - 0x2e, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, - 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x30, - 0x01, 0x42, 0x6f, 0x5a, 0x6d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x63, 0x61, 0x63, 0x68, 0x65, 0x62, 0x6f, 0x78, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, - 0x2f, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x2d, 0x63, 0x61, 0x63, 0x68, 0x65, - 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x2f, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, - 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x3b, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, - 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, - 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x68, 0x61, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x30, 0x01, 0x42, 0x6f, 0x5a, 0x6d, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x61, 0x63, 0x68, 0x65, + 0x62, 0x6f, 0x78, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x69, 0x6e, 0x66, 0x65, + 0x72, 0x65, 0x6e, 0x63, 0x65, 0x2d, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2f, 0x70, 0x6b, 0x67, 0x2f, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x69, 0x6e, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2f, 0x76, 0x31, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x31, 0x3b, 0x69, 0x6e, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x70, 0x62, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( diff --git a/proto/inferencecache/v1alpha1/inferencecache.proto b/proto/inferencecache/v1alpha1/inferencecache.proto index 6252d6e4..0d47c726 100644 --- a/proto/inferencecache/v1alpha1/inferencecache.proto +++ b/proto/inferencecache/v1alpha1/inferencecache.proto @@ -96,6 +96,21 @@ message LookupRouteRequest { // routing config, not tokenizer knowledge). repeated uint32 token_ids = 9; string prompt_text = 10; + // Adapter (e.g. LoRA) the request targets. The content fingerprint is derived + // from token IDs ALONE, so two requests with identical tokens under different + // adapters produce the SAME prefix_hash / block_hashes. adapter_id partitions + // the INDEX so those identical keys cannot alias: a lookup only ever matches + // entries ingested under the same adapter_id. It is NOT mixed into the hash — + // the fingerprint construction and its golden vectors are unchanged. + // + // Empty/unset is the default partition and is exactly the behavior before this + // field existed: a single-adapter (or LoRA-free) deployment ingests and looks + // up under "" and is unaffected. A lookup that sets adapter_id will NOT match + // entries ingested without one (and vice versa) — producer and consumer must + // agree on the identifier, the same rule hash_scheme already follows. The + // server treats the value as an opaque string and never interprets it. + // Additive, v1alpha1-compatible. + string adapter_id = 11; } // CacheTier ranks WHERE a replica holds a matched prefix, from the most local / @@ -139,6 +154,14 @@ message LookupRouteResponse { // (e.g. /v1/completions with a token-ID prompt) so the engine caches the same // tokens the fingerprint was computed over — match by construction. repeated uint32 token_ids = 4; + // Echo of the adapter partition the index was consulted in — the request's + // adapter_id. Lets a caller confirm end-to-end which partition answered + // without correlating back to its own request. Empty on the default + // (no-adapter) partition, which is what every pre-adapter client sees, and + // also empty on the fail-open envelopes returned BEFORE the index is + // consulted (TIMEOUT, tokenizer fail-open, POLICY_REQUIRES_CHAIN), where no + // partition was resolved. Additive, v1alpha1-compatible. + string adapter_id = 5; } message LookupPDRouteRequest { @@ -168,6 +191,13 @@ message PrefixEntry { // for exact-match indexing (legacy path). repeated bytes block_hashes = 3; repeated int32 block_token_counts = 4; + // Adapter (e.g. LoRA) whose KV this entry describes. Per-ENTRY rather than + // per-update because one engine replica can hold KV for several adapters at + // once — vLLM's BlockStored carries a per-event lora_id, and a subscriber + // batches events across adapters into a single CacheStateUpdate. Empty falls + // back to CacheStateUpdate.adapter_id, and an empty result is the default + // partition (legacy behavior). Additive, v1alpha1-compatible. + string adapter_id = 5; } message ReplicaStats { @@ -209,6 +239,13 @@ message CacheStateUpdate { int64 timestamp_us = 5; repeated PrefixEntry prefixes = 6; ReplicaStats stats = 7; + // Default adapter partition for every PrefixEntry in this update that does not + // set its own adapter_id. Convenience for a producer whose replica serves a + // single adapter; a multi-adapter producer sets it per entry instead. Empty = + // the default partition (legacy behavior). Stats are adapter-independent (one + // ReplicaStats per replica) and are NOT partitioned by this field. Additive, + // v1alpha1-compatible. + string adapter_id = 8; } message CacheSummary { @@ -240,6 +277,15 @@ message CacheEvent { string tenant_id = 4; bytes prefix_hash = 5; int64 timestamp_us = 6; + // Adapter partition a PREFIX_EVICTED removal targets. Set, the server drops + // the prefix only from that partition; empty, it drops the prefix across + // EVERY adapter partition — the conservative legacy behavior (removal is + // soft state: an over-eager drop costs a cache miss, never a wrong answer), + // and identical to today for producers that never populate the field. + // Ignored by ALL_CLEARED (a cache flush clears the replica across every + // adapter) and by REPLICA_UPDATED (liveness is adapter-independent). + // Additive, v1alpha1-compatible. + string adapter_id = 7; } message StreamEventsRequest { From 9cea4e8dbbf149cf026ed16f17311d98bedfd990 Mon Sep 17 00:00:00 2001 From: Ed Sun Date: Wed, 22 Jul 2026 18:02:09 -0700 Subject: [PATCH 2/7] fix: correct single-adapter guidance and harden the adapter smoke probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the LoRA index-partition change. Only a NIL lora_id (base model) uses the default "" partition; ANY non-nil lora_id — including a single-adapter deployment — resolves to a configured name or the lora: fallback, never "". So a single-LoRA deployment does not "just work": the subscriber's ingest identifier and the gateway's LookupRoute query identifier must agree end-to-end or every lookup silently misses. Corrected the wording in three places that conflated "single-adapter LoRA" with "LoRA-free": - --lora-adapter-names flag help (cmd/kvevent-subscriber/main.go) - Config.AdapterNames doc (pkg/adapters/engine/config.go) - the compat claim in docs/design/grpc-contract.md Also hardened the two negative adapter probes in the install-smoke script: they only rejected PREFIX_MATCH, so a grpcurl failure or empty response falsely passed. They now require the RPC to succeed with a non-empty body AND assert the expected fail-open reason_code (AFFINITY_HINT) — an RPC error is now a red, not a green. Dropped the stats from the seed ingest so the fail-open code is deterministically AFFINITY_HINT rather than possibly TENANT_HOT. --- cmd/kvevent-subscriber/main.go | 2 +- docs/design/grpc-contract.md | 2 +- .../scripts/default_install_smoke.sh | 60 ++++++++++++++----- pkg/adapters/engine/config.go | 13 +++- 4 files changed, 58 insertions(+), 19 deletions(-) diff --git a/cmd/kvevent-subscriber/main.go b/cmd/kvevent-subscriber/main.go index 890de276..7bb80eda 100644 --- a/cmd/kvevent-subscriber/main.go +++ b/cmd/kvevent-subscriber/main.go @@ -50,7 +50,7 @@ func main() { cacheTier = flag.String("cache-tier", "auto", `which vLLM cache-usage gauge to read: "auto" (kv→gpu→cpu fallback) | "kv" | "gpu" | "cpu"`) engineModel = flag.String("engine-model-name", "", `value of the engine's `+"`model_name`"+` Prometheus label to filter /metrics by (e.g. "Qwen/Qwen2.5-0.5B-Instruct"). Distinct from --model-id (the cache-plane index key). Empty = no label filter (aggregates every series — fine when the engine serves one model).`) ignoreBlockRemoved = flag.Bool("ignore-block-removed", false, `drop BlockRemoved events instead of forwarding them as PREFIX_EVICTED. Set for engines paired with an L2 cache tier (e.g. LMCache); default off for single-tier deployments. See docs/design/kvevent-subscriber-wiring.md "L2 cache tier semantics".`) - adapterNames = flag.String("lora-adapter-names", "", `comma-separated `+"`id=name`"+` map from the engine's internal LoRA id (BlockStored.lora_id, assigned in --lora-modules load order) to the stable adapter identity used as the index partition — the same string the gateway sends as LookupRouteRequest.adapter_id (e.g. "1=sql-lora,2=chat-lora"). Unmapped ids fall back to "lora:". Leave empty for single-adapter or LoRA-free deployments.`) + adapterNames = flag.String("lora-adapter-names", "", `comma-separated `+"`id=name`"+` map from the engine's internal LoRA id (BlockStored.lora_id, assigned in --lora-modules load order) to the stable adapter identity used as the index partition — the same string the gateway sends as LookupRouteRequest.adapter_id (e.g. "1=sql-lora,2=chat-lora"). Unmapped ids fall back to "lora:". Omit ONLY for base-model / non-LoRA traffic (nil lora_id → the default "" partition). ANY LoRA adapter — even a single one — carries a non-nil lora_id that never maps to "", so the ingest identifier here MUST agree end-to-end with the gateway's LookupRoute query identifier or every lookup silently misses: either configure the mapping (both sides use the name) or deliberately use the "lora:" fallback on both sides.`) ) flag.Parse() diff --git a/docs/design/grpc-contract.md b/docs/design/grpc-contract.md index 6e7b30e1..ecfaf06f 100644 --- a/docs/design/grpc-contract.md +++ b/docs/design/grpc-contract.md @@ -267,7 +267,7 @@ The fix partitions the **index**, not the hash: - `LookupRouteResponse.adapter_id` (5) echoes the partition the index was consulted in, so a caller can confirm the round trip. It is empty on the fail-open envelopes returned *before* the index is consulted (`TIMEOUT`, tokenizer fail-open, `POLICY_REQUIRES_CHAIN`). - `CacheEvent.adapter_id` (7) narrows a `PREFIX_EVICTED` to one partition. Without it, one adapter's GPU eviction would drop the identical hash under *every* adapter — discarding hints that are still valid. Empty keeps the original cross-partition sweep. -**Producer / consumer must agree, exactly as they already do for `hash_scheme`.** A lookup that sets `adapter_id` will not match entries ingested without one, and vice versa. Empty/unset is the **default partition** and is exactly the pre-adapter behavior: a single-adapter or LoRA-free deployment ingests and looks up under `""` and is completely unaffected — no regression, no configuration required. +**Producer / consumer must agree, exactly as they already do for `hash_scheme`.** A lookup that sets `adapter_id` will not match entries ingested without one, and vice versa. Empty/unset is the **default partition**, and this is exactly the pre-adapter behavior **for base-model / non-LoRA traffic**: an engine serving no LoRA emits a nil `lora_id`, so ingest and lookup both land under `""` and are completely unaffected — no regression, no configuration required. **This is not a "single LoRA adapter just works" claim.** Only a *nil* `lora_id` uses `""`; *any* non-nil `lora_id` — including a deployment with exactly one adapter — resolves to a configured name or the `lora:` fallback and never `""`. So even one LoRA adapter requires the ingest identifier (the subscriber's resolved `adapter_id`) to match the gateway's query `adapter_id` end-to-end, or every lookup silently misses — the same agreement `hash_scheme` already demands. **Adapter identity is a stable string, not the engine's integer.** vLLM's `lora_id` is an internal load-order integer, so the *same* integer can mean different adapters on two replicas whose `--lora-modules` order differs — and the index is shared across replicas. The `kvevent-subscriber` therefore maps it to a stable identity via `--lora-adapter-names` (`id=name`, e.g. `1=sql-lora,2=chat-lora`), the same string the gateway sends as `adapter_id`. An unmapped id falls back to `lora:`, which is exact within a replica and agrees across replicas that share an adapter load order (the homogeneous-Deployment case). diff --git a/docs/reference-stack/scripts/default_install_smoke.sh b/docs/reference-stack/scripts/default_install_smoke.sh index a58cb473..7ad6d852 100755 --- a/docs/reference-stack/scripts/default_install_smoke.sh +++ b/docs/reference-stack/scripts/default_install_smoke.sh @@ -1364,8 +1364,15 @@ adapter_model="install-smoke-adapter" adapter_replica="adapter-smoke-replica" adapter_hash_b64="YWRhcHRlci1wcmVmaXg=" # base64("adapter-prefix") — stored at tokenCount=64, clears both policy gates +# No stats in this update: a warm hit_rate would let the negative probes below +# qualify for TENANT_HOT, making the expected fail-open code ambiguous between +# TENANT_HOT and AFFINITY_HINT. Without stats the non-matching adapter partitions +# fall straight through to the affinity fallback, so the expected code is +# deterministically AFFINITY_HINT (the replica still SERVES the adapter-blind +# scope via the prefix ingest, so affinity has a candidate). PREFIX_MATCH on the +# same adapter needs only the prefix entry, not stats. adapter_report_payload="$(cat <&2 - echo "$adapter_other_resp" >&2 - fail "LookupRoute for adapter_id=smoke-lora-b matched a prefix ingested under smoke-lora-a — identical token content aliased across adapters, which would route to a replica holding a DIFFERENT adapter's KV" -fi +# assert_adapter_no_alias runs one NEGATIVE adapter probe. The property under +# test is "the identical prefix hash does NOT match in a different partition," +# but a transport error mustn't masquerade as that: grpcurl failing or returning +# an empty body would leave has_reason_code false and silently pass. So require +# the RPC to SUCCEED and return a non-empty body, then assert the expected +# fail-open reason_code — AFFINITY_HINT, because affinityRouting is Enabled for +# this tenant (the policy block above proved it) and the replica serves the +# adapter-blind (tenant, model, hash_scheme) scope, so a non-matching adapter +# partition downgrades to a stable affinity pick rather than a prefix hit. An +# RPC error is therefore a red, not a green. +assert_adapter_no_alias() { + local payload="$1" err_file="$2" label="$3" alias_msg="$4" + local resp + if ! resp="$(grpcurl_lookup_route "$payload" "$err_file")" || [ -z "$resp" ]; then + if [ -s "$err_file" ]; then + cat "$err_file" >&2 + fi + echo "$label LookupRoute response: ${resp:-}" >&2 + fail "$label LookupRoute returned no response — an RPC/transport error must fail the adapter-partition probe, not silently pass it as a non-match" + fi + if has_reason_code "$resp" "PREFIX_MATCH"; then + echo "$label LookupRoute response (want NOT PREFIX_MATCH):" >&2 + echo "$resp" >&2 + fail "$alias_msg" + fi + if ! has_reason_code "$resp" "AFFINITY_HINT"; then + echo "$label LookupRoute response (want fail-open AFFINITY_HINT):" >&2 + echo "$resp" >&2 + fail "$label LookupRoute did not return the expected fail-open AFFINITY_HINT — a non-matching adapter partition must downgrade to a stable affinity pick, never error and never PREFIX_MATCH" + fi +} -adapter_none_resp="$(grpcurl_lookup_route "$adapter_none_payload" "$LOG_DIR/grpcurl-adapter-none.err")" || true -if has_reason_code "$adapter_none_resp" "PREFIX_MATCH"; then - echo "no-adapter LookupRoute response (want NOT PREFIX_MATCH):" >&2 - echo "$adapter_none_resp" >&2 - fail "LookupRoute without adapter_id matched an adapter-scoped prefix — adapter-scoped ingest must stay out of the default partition" -fi -log "adapter partition enforced end-to-end: same adapter_id → PREFIX_MATCH; a different adapter_id and an absent adapter_id both stayed off the prefix-match path on the identical prefix hash" +assert_adapter_no_alias "$adapter_other_payload" "$LOG_DIR/grpcurl-adapter-other.err" \ + "different-adapter" \ + "LookupRoute for adapter_id=smoke-lora-b matched a prefix ingested under smoke-lora-a — identical token content aliased across adapters, which would route to a replica holding a DIFFERENT adapter's KV" +assert_adapter_no_alias "$adapter_none_payload" "$LOG_DIR/grpcurl-adapter-none.err" \ + "no-adapter" \ + "LookupRoute without adapter_id matched an adapter-scoped prefix — adapter-scoped ingest must stay out of the default partition" +log "adapter partition enforced end-to-end: same adapter_id → PREFIX_MATCH; a different adapter_id and an absent adapter_id each returned the fail-open AFFINITY_HINT (RPC succeeded, off the prefix-match path) on the identical prefix hash" # --- routingFloorScore end-to-end probe ------------------------------------ # Proves the new field flows CR → controller flatten → /policy push → server diff --git a/pkg/adapters/engine/config.go b/pkg/adapters/engine/config.go index 40b0cb3f..8bde427b 100644 --- a/pkg/adapters/engine/config.go +++ b/pkg/adapters/engine/config.go @@ -39,9 +39,16 @@ type Config struct { // adapter load order (the homogeneous-Deployment case). Supply the map — from // the same --lora-modules ordering the engine gets — for deployments whose // replicas can diverge (runtime load/unload, rolling updates), and supply the - // matching adapter_id from the gateway. Nil/empty is the common single-adapter - // or LoRA-free deployment: every event has no lora_id and lands in the default - // ("") partition, i.e. exactly the pre-adapter behavior. + // matching adapter_id from the gateway. + // + // Only a NIL lora_id (base model / no LoRA) uses the default ("") partition. + // A non-nil lora_id — INCLUDING a single-adapter deployment — always resolves + // to a configured name or the "lora:" fallback, never "". So a LoRA + // deployment does not "just work": whatever identity is ingested here MUST + // match the adapter_id the gateway queries with, or every lookup silently + // misses (same producer/consumer agreement HashScheme requires). Nil/empty + // AdapterNames is correct only for base-model / non-LoRA traffic, or when + // both sides deliberately rely on the "lora:" fallback. AdapterNames map[int64]string } From 9dd2639bef4b7ecb5702fa6598438101d3639110 Mon Sep 17 00:00:00 2001 From: Ed Sun Date: Wed, 22 Jul 2026 18:20:55 -0700 Subject: [PATCH 3/7] fix: correct proto adapter_id comment and complete the quota tie-break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more review follow-ups on the LoRA index-partition change. 1. proto/inferencecache/v1alpha1/inferencecache.proto — the adapter_id (11) comment still carried the "single-adapter deployment uses the empty partition and is unaffected" claim that was corrected everywhere else, contradicting grpc-contract.md/config.go/main.go. Applied the same correction: only a nil lora_id uses the "" partition; any LoRA adapter, even one, resolves to a name or lora: and needs end-to-end identifier agreement. Regenerated the .pb.go (the comment flows into generated code; proto-gen stays idempotent) and gave the request/response adapter_id fields an inline description in grpc-contract.md to match. 2. pkg/index/index.go — the tenant-quota eviction tie-break added adapter but still tied on keys differing by model or hashScheme, leaving the victim dependent on map iteration order (contradicting its own comment). It now breaks age ties on the full remaining key (model, hashScheme, adapter, prefixHash), mirroring the cap-sweep comparator. Added a test that pins deterministic victim selection across each of those fields (64 fresh index instances per field so an incomplete comparator flakes here). --- docs/design/grpc-contract.md | 2 + pkg/index/index.go | 23 ++++-- pkg/index/tenant_quota_test.go | 74 +++++++++++++++++++ .../v1alpha1/inferencecache.pb.go | 16 ++-- .../v1alpha1/inferencecache.proto | 16 ++-- 5 files changed, 112 insertions(+), 19 deletions(-) diff --git a/docs/design/grpc-contract.md b/docs/design/grpc-contract.md index ecfaf06f..b8eae59f 100644 --- a/docs/design/grpc-contract.md +++ b/docs/design/grpc-contract.md @@ -45,7 +45,9 @@ service InferenceCache { - **RenderTemplateResponse** `{ bytes rendered_prompt; bytes stable_prefix_hash; bytes tenant_namespace; string reason_code; string template_revision; }` reason ∈ `OK | TEMPLATE_NOT_FOUND | RENDER_ERROR` - **LookupRouteRequest** `{ string model_id = 1; string tenant_id = 2; bytes prefix_hash = 3; int32 prefix_token_count = 4; string hash_scheme = 5; SLO slo = 6; repeated bytes block_hashes = 7; repeated int32 block_token_counts = 8; repeated uint32 token_ids = 9; string prompt_text = 10; string adapter_id = 11; }` + `adapter_id` selects the **index partition** to look up in (see "Update — adapter (LoRA) index partition" below). Empty is the default partition and matches the pre-adapter behavior **only for base-model / non-LoRA traffic**; because the fingerprint is token-only, *any* LoRA adapter (even one) must set the same `adapter_id` the producer ingested under — never `""` — or the lookup silently misses. - **LookupRouteResponse** `{ repeated ReplicaScore replica_scores; string reason_code; int64 lookup_latency_us; repeated uint32 token_ids = 4; string adapter_id = 5; }` + `adapter_id` echoes the partition the index was consulted in (the request's `adapter_id`); empty on the fail-open envelopes returned before the index is consulted (`TIMEOUT`, tokenizer fail-open, `POLICY_REQUIRES_CHAIN`). reason ∈ `PREFIX_MATCH | TENANT_HOT | AFFINITY_HINT | NO_HINT | POLICY_REQUIRES_CHAIN | TIMEOUT | UNKNOWN_TENANT | UNKNOWN_MODEL | UNKNOWN_HASH_SCHEME` - **LookupPDRouteRequest** `{ string model_id; string tenant_id; bytes prefix_hash; int32 prefix_token_count; string pd_topology_ref; }` - **LookupPDRouteResponse** `{ string prefill_replica_id; string decode_replica_id; string transport_hint; string reason_code; }` diff --git a/pkg/index/index.go b/pkg/index/index.go index d1279b0c..19648688 100644 --- a/pkg/index/index.go +++ b/pkg/index/index.go @@ -2547,17 +2547,26 @@ func (i *Index) evictOldestForTenantLocked(tenant string, maxPrefixes int64) int all = append(all, ref{key, newest}) } sort.Slice(all, func(a, b int) bool { + x, y := all[a].key, all[b].key if !all[a].age.Equal(all[b].age) { return all[a].age.Before(all[b].age) } - if all[a].key.prefixHash != all[b].key.prefixHash { - return all[a].key.prefixHash < all[b].key.prefixHash + // Break age ties on the FULL remaining key so victim selection never + // depends on map iteration order. tenant is constant here (this helper + // only scans one tenant's prefixes), but model, hashScheme, and adapter + // are all free to differ within it — and two keys can even share a + // prefixHash across adapters (the fingerprint is token-only). Compare + // every field that completes the key, mirroring the cap-sweep comparator. + if x.model != y.model { + return x.model < y.model } - // Same tenant + same prefix hash under two different adapters is a - // genuine pair of distinct keys — the fingerprint is token-only — so the - // adapter has to break the tie or the victim order would depend on map - // iteration order. - return all[a].key.adapter < all[b].key.adapter + if x.hashScheme != y.hashScheme { + return x.hashScheme < y.hashScheme + } + if x.adapter != y.adapter { + return x.adapter < y.adapter + } + return x.prefixHash < y.prefixHash }) removed := 0 for _, r := range all { diff --git a/pkg/index/tenant_quota_test.go b/pkg/index/tenant_quota_test.go index 023c7b95..91638424 100644 --- a/pkg/index/tenant_quota_test.go +++ b/pkg/index/tenant_quota_test.go @@ -199,3 +199,77 @@ func TestTenantQuotaZeroBudgetEvictsAll(t *testing.T) { t.Fatalf("tenant entries = %d, want 0 (zero budget)", got) } } + +// The quota-eviction victim is chosen by (age, then the full remaining prefix +// key). When two of a tenant's prefixes share an age, the tie MUST break on +// every remaining key field — model, hashScheme, adapter, prefixHash — or the +// victim would depend on Go's randomized map iteration order. This pins that +// each field participates: two same-age keys differing in exactly ONE field are +// ingested with a budget of 1, so precisely the analytically-smaller key is +// evicted, and the scenario is repeated enough times that an incomplete +// comparator (which would treat the pair as equal and pick a map-order victim) +// would flake here rather than in production. +func TestTenantQuotaTieBreakIsDeterministicAcrossFullKey(t *testing.T) { + // key varies one field of the prefix identity; lo sorts before hi, so lo is + // the deterministic eviction victim and hi must survive. + type keyid struct{ model, scheme, adapter, prefix string } + cases := map[string]struct{ lo, hi keyid }{ + "model": { + lo: keyid{"m1", "vllm", "", "p"}, + hi: keyid{"m2", "vllm", "", "p"}, + }, + "hashScheme": { + lo: keyid{"m", "vllm-a", "", "p"}, + hi: keyid{"m", "vllm-b", "", "p"}, + }, + "adapter": { + lo: keyid{"m", "vllm", "lora-a", "p"}, + hi: keyid{"m", "vllm", "lora-b", "p"}, + }, + "prefixHash": { + lo: keyid{"m", "vllm", "lora", "p1"}, + hi: keyid{"m", "vllm", "lora", "p2"}, + }, + } + + ingest := func(idx *Index, k keyid, ts time.Time) { + idx.Ingest(Update{ + ReplicaID: "r1", + Model: k.model, + Tenant: "team", + HashScheme: k.scheme, + Adapter: k.adapter, + Timestamp: ts, + Prefixes: []PrefixRef{{PrefixHash: hash(k.prefix), TokenCount: 1}}, + }) + } + held := func(idx *Index, k keyid) bool { + return len(idx.Lookup(LookupRequest{ + Model: k.model, Tenant: "team", HashScheme: k.scheme, + Adapter: k.adapter, PrefixHash: hash(k.prefix), + })) > 0 + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + // Many independent index instances → many independent map seeds, so a + // tie-break that ignored this field would evict hi on some runs. + for iter := 0; iter < 64; iter++ { + base := time.Unix(20_000_000, 0) + clk := &fakeClock{t: base.Add(time.Minute)} // both entries fresh, equal age + idx := New(withClock(clk.now), WithTenantQuotaResolver(fakeQuota{"team": 1})) + + // Same timestamp for both → identical age → the tie-break decides. + ingest(idx, tc.hi, base) // ingest hi first so insertion order can't be what saves it + ingest(idx, tc.lo, base) // second ingest pushes over budget 1 → exactly one eviction + + if held(idx, tc.lo) { + t.Fatalf("iter %d: lo key %+v survived — it is the smaller of the tie and must be the victim", iter, tc.lo) + } + if !held(idx, tc.hi) { + t.Fatalf("iter %d: hi key %+v was evicted — victim selection is not deterministic on the %s field (map-iteration-order dependent)", iter, tc.hi, name) + } + } + }) + } +} diff --git a/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go b/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go index bebacc2a..414b5950 100644 --- a/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go +++ b/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go @@ -375,12 +375,16 @@ type LookupRouteRequest struct { // the fingerprint construction and its golden vectors are unchanged. // // Empty/unset is the default partition and is exactly the behavior before this - // field existed: a single-adapter (or LoRA-free) deployment ingests and looks - // up under "" and is unaffected. A lookup that sets adapter_id will NOT match - // entries ingested without one (and vice versa) — producer and consumer must - // agree on the identifier, the same rule hash_scheme already follows. The - // server treats the value as an opaque string and never interprets it. - // Additive, v1alpha1-compatible. + // field existed — but only for base-model / non-LoRA traffic: an engine + // serving no LoRA emits a nil lora_id, so ingest and lookup both land under "" + // and are unaffected. This is NOT a "single LoRA adapter just works" claim: + // only a nil lora_id uses ""; ANY LoRA adapter (even one) resolves to a + // configured name or the "lora:" fallback and never "". A lookup that sets + // adapter_id will NOT match entries ingested without one (and vice versa), so + // even one adapter requires the ingest identifier to agree with the query + // adapter_id end-to-end or every lookup silently misses — the same rule + // hash_scheme already follows. The server treats the value as an opaque string + // and never interprets it. Additive, v1alpha1-compatible. AdapterId string `protobuf:"bytes,11,opt,name=adapter_id,json=adapterId,proto3" json:"adapter_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/proto/inferencecache/v1alpha1/inferencecache.proto b/proto/inferencecache/v1alpha1/inferencecache.proto index 0d47c726..cf86e42f 100644 --- a/proto/inferencecache/v1alpha1/inferencecache.proto +++ b/proto/inferencecache/v1alpha1/inferencecache.proto @@ -104,12 +104,16 @@ message LookupRouteRequest { // the fingerprint construction and its golden vectors are unchanged. // // Empty/unset is the default partition and is exactly the behavior before this - // field existed: a single-adapter (or LoRA-free) deployment ingests and looks - // up under "" and is unaffected. A lookup that sets adapter_id will NOT match - // entries ingested without one (and vice versa) — producer and consumer must - // agree on the identifier, the same rule hash_scheme already follows. The - // server treats the value as an opaque string and never interprets it. - // Additive, v1alpha1-compatible. + // field existed — but only for base-model / non-LoRA traffic: an engine + // serving no LoRA emits a nil lora_id, so ingest and lookup both land under "" + // and are unaffected. This is NOT a "single LoRA adapter just works" claim: + // only a nil lora_id uses ""; ANY LoRA adapter (even one) resolves to a + // configured name or the "lora:" fallback and never "". A lookup that sets + // adapter_id will NOT match entries ingested without one (and vice versa), so + // even one adapter requires the ingest identifier to agree with the query + // adapter_id end-to-end or every lookup silently misses — the same rule + // hash_scheme already follows. The server treats the value as an opaque string + // and never interprets it. Additive, v1alpha1-compatible. string adapter_id = 11; } From 84156be19979f097ca9c9e1af1d09c89b39209a9 Mon Sep 17 00:00:00 2001 From: Ed Sun Date: Wed, 22 Jul 2026 18:45:39 -0700 Subject: [PATCH 4/7] docs: scope the static --lora-adapter-names flag; fix non-LoRA comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name the two gaps the startup flag can't close — runtime-loaded adapters, and the managed (webhook-injected) sidecar — as tracked follow-ups, so the docs stop implying they're solved by "supply the map" / "set it explicitly". Correct the index.go partition comment: the default partition is the non-LoRA case, not "single-adapter". --- docs/design/kvevent-subscriber-wiring.md | 28 +++++++++++++++++++----- pkg/index/index.go | 2 +- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/design/kvevent-subscriber-wiring.md b/docs/design/kvevent-subscriber-wiring.md index d5265106..d5375d58 100644 --- a/docs/design/kvevent-subscriber-wiring.md +++ b/docs/design/kvevent-subscriber-wiring.md @@ -169,15 +169,33 @@ stable identity the gateway sends as `LookupRouteRequest.adapter_id`: - **Unmapped id** → `lora:`. Exact within a replica, and consistent across replicas that share an adapter load order (the homogeneous-Deployment case). - Supply the map when replicas can diverge — runtime load/unload, rolling - updates — and make the gateway send the matching `adapter_id`. + Supply the map for adapters **known at startup** when replicas can diverge + (rolling updates, heterogeneous load order), and make the gateway send the + matching `adapter_id`. - **No LoRA at all** (the default): every event carries a nil `lora_id`, so every entry and eviction lands in the default (`""`) partition — byte-for-byte the pre-adapter behavior. Nothing to configure. -The flag is not set by the CacheBackend-driven injection path today: the -controller has no view of the engine's `--lora-modules` ordering, so operators -running multi-adapter engines set it on the subscriber container explicitly. +### Limitations of the static flag + +`--lora-adapter-names` is resolved once at **startup**, which leaves two gaps — +both a tracked follow-up, and neither a regression (an unmapped id still +partitions by `lora:`, strictly fewer collisions than the pre-adapter +single-partition behavior): + +- **Runtime loads.** An adapter loaded or reloaded *after* the subscriber starts + whose id isn't already in the map falls back to `lora:` — a static list + can't name it. Covering arbitrary runtime loads needs the subscriber to + resolve adapter identity **dynamically** from the engine's adapter registry. +- **Managed injection.** On the `CacheBackend`-driven path the subscriber + sidecar is **injected**, so operators cannot pass the flag on a container they + do not define; the explicit-set guidance below applies only to a + **self-managed** subscriber. Configuring the mapping through the managed path + needs a `CacheBackend` field the webhook forwards. + +For a self-managed subscriber, set `--lora-adapter-names` on the container +directly: the controller has no view of the engine's `--lora-modules` ordering, +so multi-adapter engines must supply the map explicitly. ## What this unblocks diff --git a/pkg/index/index.go b/pkg/index/index.go index 19648688..6ca98c57 100644 --- a/pkg/index/index.go +++ b/pkg/index/index.go @@ -269,7 +269,7 @@ type LookupRequest struct { // the producer ingested under or it will not match — the same producer/ // consumer agreement HashScheme already requires. Empty is the default // partition, which is where every non-LoRA deployment both ingests and looks - // up, so single-adapter behavior is unchanged. + // up, so non-LoRA behavior is unchanged. Adapter string PrefixHash []byte TokenCount int32 From 10fb7d0cb3a7ea79ceaec37ce47d3ab64999fc66 Mon Sep 17 00:00:00 2001 From: Ed Sun Date: Thu, 23 Jul 2026 12:16:45 -0700 Subject: [PATCH 5/7] docs(index): note the empty-adapter eviction over-sweep in the comment An adapter-aware producer emits adapter="" for a genuine base-model eviction, so the "" cross-partition sweep can also drop live LoRA-partition hints for the same prefix hash. Conservative soft state (re-added by ReportCacheState) and a strict non-regression, but the comment shouldn't frame "" as only the pre-adapter case. Precise fix (adapter_id presence on the event) is a tracked follow-up. --- pkg/index/index.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pkg/index/index.go b/pkg/index/index.go index 6ca98c57..fd5e6a9d 100644 --- a/pkg/index/index.go +++ b/pkg/index/index.go @@ -893,10 +893,19 @@ func (i *Index) ApplyEvent(ev Event) { // Adapter, unlike scheme, IS narrowed when the producer supplies it. The // fingerprint is token-only, so one prefix hash can be live under several // adapters at once on the same replica; dropping every partition for one - // adapter's GPU eviction would throw away hints that are still valid. An - // empty ev.Adapter keeps the original cross-partition sweep, which is - // exactly what a pre-adapter producer gets (all its entries are in the "" - // partition anyway). + // adapter's GPU eviction would throw away hints that are still valid. + // + // An empty ev.Adapter falls back to the original cross-partition sweep. + // That is exact for a pre-adapter producer (all its entries are in the "" + // partition anyway), but an adapter-aware producer ALSO emits "" for a + // genuine base-model eviction — and the sweep then drops live LoRA- + // partition hints for the same prefix hash too. That is conservative soft + // state (at worst a cache miss, re-added by the authoritative + // ReportCacheState path) and a strict non-regression (pre-partition, base + // and LoRA shared one partition, so a base eviction was already total). + // Making "" mean the base partition only, without breaking the legacy + // wildcard, needs explicit adapter_id presence on the event — a tracked + // follow-up. for key, replicas := range i.prefixes { if key.tenant != ev.Tenant || key.model != ev.Model || key.prefixHash != hash { continue From 54481fa635a03e412a7aa53a2dba6b4338af9f2e Mon Sep 17 00:00:00 2001 From: Ed Sun Date: Thu, 23 Jul 2026 13:16:04 -0700 Subject: [PATCH 6/7] docs: fix adapter_id field number + adapter in key identity (touched files) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge consistency, all in files this PR already changes: - grpc-contract.md: the adapter-partition section said PrefixEntry.adapter_id is field 5; the merge moved it to 6 (5 is now tier). Correct the number. - grpc-contract.md + proto PrefixEntry.tier comment: the tier last-write-wins identity now includes adapter_id (partitioned key), not (…, prefix_hash) alone. - index.go: prefixesByTenant + Aggregate comments count a distinct key that now includes adapter. Cross-doc key drift in files this PR does not touch stays a tracked follow-up. pb.go regenerated (comment mirror only, no wire change); go test ./..., vet, gofmt, buf lint pass. --- docs/design/grpc-contract.md | 4 ++-- pkg/index/index.go | 6 +++--- .../proto/inferencecache/v1alpha1/inferencecache.pb.go | 6 +++--- proto/inferencecache/v1alpha1/inferencecache.proto | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/design/grpc-contract.md b/docs/design/grpc-contract.md index 7d3ec614..3ce927fc 100644 --- a/docs/design/grpc-contract.md +++ b/docs/design/grpc-contract.md @@ -57,7 +57,7 @@ service InferenceCache { `tier` is the **CacheTier** from which the replica can serve the **entire** matched prefix — for a block-chain match, the least-local (coldest) tier among the matched blocks: one block only present in a colder tier constrains the whole run, so a warmer claim would overstate the hint. Per-block holds are reported most-local at ingest; only the across-run summary takes the constraining view. `CacheTier ∈ CACHE_TIER_UNSPECIFIED (0) | CACHE_TIER_T1 (1) | CACHE_TIER_T2 (2) | CACHE_TIER_T3 (3)`, ordered most-local (T1 — engine KV cache) to coldest (T3 — remote / disaggregated); T2 is external offload (e.g. LMCache). `UNSPECIFIED` on a hint with no per-prefix evidence (`TENANT_HOT` / `AFFINITY_HINT`), so old clients that ignore the field are unaffected (additive, v1alpha1-compatible). Tier *detection* is done by the `kvevent-subscriber` from the engine block lifecycle (stored → `T1`; evicted-but-L2-retained → `T2` — see `PrefixEntry.tier`); `T3` (remote / disaggregated) is not yet emitted. The field carries the value end-to-end but is not yet a routing input (the ranker does not read `tier`). - **CacheStateUpdate** `{ string replica_id; string model_id; string tenant_id; string hash_scheme; int64 timestamp_us; repeated PrefixEntry prefixes; ReplicaStats stats; string adapter_id = 8; }` - **PrefixEntry** `{ bytes prefix_hash = 1; int32 token_count = 2; repeated bytes block_hashes = 3; repeated int32 block_token_counts = 4; CacheTier tier = 5; string adapter_id = 6; }` — **metadata only** - `tier` is the **CacheTier** the reporting replica holds the entry in, set by the producer (`kvevent-subscriber`) from the engine's block lifecycle: a stored block is resident in the engine KV cache (`T1`); a block the engine evicts from HBM while a paired L2 offload tier (e.g. LMCache) still retains it is re-reported at `T2` (reload-able from the offload tier) rather than removed. `UNSPECIFIED (0)` is the wire default and the server normalizes it to `T1` at ingest, so an older producer that never sets the field still lands its stored prefixes at `T1`. A re-report of the same `(tenant_id, model_id, replica_id, hash_scheme, prefix_hash)` with a different tier moves the entry between tiers (**last write wins**) — an eviction downgrades `T1 → T2`, a later re-store upgrades `T2 → T1`. A chain entry's tier applies to every block it expands to. Additive, v1alpha1-compatible. + `tier` is the **CacheTier** the reporting replica holds the entry in, set by the producer (`kvevent-subscriber`) from the engine's block lifecycle: a stored block is resident in the engine KV cache (`T1`); a block the engine evicts from HBM while a paired L2 offload tier (e.g. LMCache) still retains it is re-reported at `T2` (reload-able from the offload tier) rather than removed. `UNSPECIFIED (0)` is the wire default and the server normalizes it to `T1` at ingest, so an older producer that never sets the field still lands its stored prefixes at `T1`. A re-report of the same `(tenant_id, model_id, replica_id, hash_scheme, adapter_id, prefix_hash)` with a different tier moves the entry between tiers (**last write wins**) — an eviction downgrades `T1 → T2`, a later re-store upgrades `T2 → T1`. A chain entry's tier applies to every block it expands to. Additive, v1alpha1-compatible. `adapter_id` is the **index partition** the entry belongs to (see "Update — adapter (LoRA) index partition" below). Per-entry rather than per-update because one replica can hold KV for several adapters at once; empty falls back to `CacheStateUpdate.adapter_id`, and an empty result is the default partition (pre-adapter behavior). Additive, v1alpha1-compatible. - **ReplicaStats** `{ string replica_id = 1; int64 cache_memory_bytes = 2; float hit_rate = 3; float pressure = 4; string client_version = 5; int64 t2_hit_tokens = 6; int64 t2_query_tokens = 7; }` `client_version` is an opaque version string identifying the client-side cache library the reporting replica is linked against (e.g. an LMCache client release). It carries no semver semantics on the wire — producers populate it, the server accepts it, no semver parsing or ordering is performed at this layer. Empty / unset is allowed and means "unknown"; older producers that don't fill the field MUST keep being accepted (additive, v1alpha1-compatible). The field reserves wire space for the producer half of an end-to-end client/server version-skew detection surface; the consumer half (server-side storage, per-CacheBackend exposure, the operator-visible status condition that closes the silent client/server mismatch class of bug) lands in a follow-up change and is intentionally out of scope for this contract update. @@ -264,7 +264,7 @@ The fingerprint is positional (block `i`'s key identifies the whole prefix `0..i The fix partitions the **index**, not the hash: -- `LookupRouteRequest` gains `adapter_id` (11) and `PrefixEntry` gains `adapter_id` (5), with `CacheStateUpdate.adapter_id` (8) as the per-update default for entries that set none. The server's index key becomes **`(tenant_id, model_id, hash_scheme, adapter_id, prefix_hash)`** — `adapter_id` joins the existing partition ahead of the content hash. +- `LookupRouteRequest` gains `adapter_id` (11) and `PrefixEntry` gains `adapter_id` (6), with `CacheStateUpdate.adapter_id` (8) as the per-update default for entries that set none. The server's index key becomes **`(tenant_id, model_id, hash_scheme, adapter_id, prefix_hash)`** — `adapter_id` joins the existing partition ahead of the content hash. - **The fingerprint construction is unchanged.** Adapter identity is never mixed into the hash, so `pkg/fingerprint`, its golden vectors, and every cross-language implementation (Go / Python / Rust) stay byte-for-byte as they were. The hash still answers "what content is this?"; the partition answers "whose KV is it?". - **Matching inside a partition is unchanged.** Exact-match and the block-chain longest-leading-run rule behave identically; the partition only bounds which entries are candidates. - `LookupRouteResponse.adapter_id` (5) echoes the partition the index was consulted in, so a caller can confirm the round trip. It is empty on the fail-open envelopes returned *before* the index is consulted (`TIMEOUT`, tokenizer fail-open, `POLICY_REQUIRES_CHAIN`). diff --git a/pkg/index/index.go b/pkg/index/index.go index 6dbfc1cb..e227cc7c 100644 --- a/pkg/index/index.go +++ b/pkg/index/index.go @@ -581,8 +581,8 @@ type Index struct { reservedEntries int // prefixesByTenant counts DISTINCT prefix keys per tenant (one per - // (tenant, model, hash_scheme, prefix_hash), regardless of how many replicas - // hold it), so the per-tenant quota check at ingest is O(1) instead of + // (tenant, model, hash_scheme, adapter, prefix_hash), regardless of how many + // replicas hold it), so the per-tenant quota check at ingest is O(1) instead of // scanning i.prefixes. Maintained by upsert/removeReplicaLocked: bumped when a // key is first created for the tenant, dropped when the key's last replica // leaves. This is the unit maxIndexEntries bounds and the unit reported as @@ -1709,7 +1709,7 @@ const DefaultTenantSentinel = "" // so they cannot disagree. Total == Σ PerTenant by construction — this is the // hard invariant the CacheIndex/CacheTenant status surfaces rely on (a tenant's // reported indexEntries always sum to the cluster prefix total). The counted -// unit is a distinct prefix key — (tenant, model, hash_scheme, prefix_hash), +// unit is a distinct prefix key — (tenant, model, hash_scheme, adapter, prefix_hash), // regardless of how many replicas hold it — which is exactly the unit // prefixes.summary.total reports and the per-tenant maxIndexEntries quota bounds. // (Tenant is part of the key, so the per-tenant partition is exact.) diff --git a/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go b/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go index c0461327..5bb143d0 100644 --- a/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go +++ b/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go @@ -837,9 +837,9 @@ type PrefixEntry struct { // default; the server normalizes it to T1 at ingest, so an older producer // that never sets the field still lands its stored prefixes at T1 // (additive, v1alpha1-compatible). A re-report of the same (replica, - // hash_scheme, prefix_hash) with a different tier moves the entry between - // tiers (last write wins) — this is how an eviction downgrades T1→T2 and a - // later re-store upgrades T2→T1. + // hash_scheme, adapter, prefix_hash) with a different tier moves the entry + // between tiers (last write wins) — this is how an eviction downgrades T1→T2 + // and a later re-store upgrades T2→T1. Tier CacheTier `protobuf:"varint,5,opt,name=tier,proto3,enum=inferencecache.v1alpha1.CacheTier" json:"tier,omitempty"` // Adapter (e.g. LoRA) whose KV this entry describes. Per-ENTRY rather than // per-update because one engine replica can hold KV for several adapters at diff --git a/proto/inferencecache/v1alpha1/inferencecache.proto b/proto/inferencecache/v1alpha1/inferencecache.proto index f5e05b0b..316f0ad2 100644 --- a/proto/inferencecache/v1alpha1/inferencecache.proto +++ b/proto/inferencecache/v1alpha1/inferencecache.proto @@ -206,9 +206,9 @@ message PrefixEntry { // default; the server normalizes it to T1 at ingest, so an older producer // that never sets the field still lands its stored prefixes at T1 // (additive, v1alpha1-compatible). A re-report of the same (replica, - // hash_scheme, prefix_hash) with a different tier moves the entry between - // tiers (last write wins) — this is how an eviction downgrades T1→T2 and a - // later re-store upgrades T2→T1. + // hash_scheme, adapter, prefix_hash) with a different tier moves the entry + // between tiers (last write wins) — this is how an eviction downgrades T1→T2 + // and a later re-store upgrades T2→T1. CacheTier tier = 5; // Adapter (e.g. LoRA) whose KV this entry describes. Per-ENTRY rather than // per-update because one engine replica can hold KV for several adapters at From 2f9a89cdcb5f5a4e2f4e70b134e6424397a5a80a Mon Sep 17 00:00:00 2001 From: Ed Sun Date: Thu, 23 Jul 2026 13:44:33 -0700 Subject: [PATCH 7/7] feat(subscriber): fail closed on an unmapped LoRA id instead of aliasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unmapped non-nil lora_id previously fell back to a replica-local "lora:" partition, which could place different adapters in one partition across replicas whose --lora-modules order differs — the cross-replica alias the partition exists to prevent. Now AdapterID reports ok=false for an unmapped id and the Reporter drops the BlockStored (warned once per id) rather than indexing it. The adapter gets no routing hint — a cache miss, never a wrong replica — until --lora-adapter-names maps it, so LoRA caching now REQUIRES the map. Base-model (nil lora_id) traffic is unchanged. Docs (config/flag/proto/grpc-contract/wiring) and tests updated; new TestReporterFailsClosedOnUnmappedAdapter. go test ./..., vet, gofmt, buf lint pass. --- cmd/kvevent-subscriber/main.go | 2 +- docs/design/grpc-contract.md | 4 +- docs/design/kvevent-subscriber-wiring.md | 34 +++++----- pkg/adapters/engine/config.go | 62 +++++++++-------- pkg/adapters/engine/forwarder.go | 53 +++++++++++---- pkg/adapters/engine/lora_adapter_test.go | 66 ++++++++++++++----- .../v1alpha1/inferencecache.pb.go | 13 ++-- .../v1alpha1/inferencecache.proto | 13 ++-- 8 files changed, 163 insertions(+), 84 deletions(-) diff --git a/cmd/kvevent-subscriber/main.go b/cmd/kvevent-subscriber/main.go index 0d15e242..3e1f211b 100644 --- a/cmd/kvevent-subscriber/main.go +++ b/cmd/kvevent-subscriber/main.go @@ -50,7 +50,7 @@ func main() { cacheTier = flag.String("cache-tier", "auto", `which vLLM cache-usage gauge to read: "auto" (kv→gpu→cpu fallback) | "kv" | "gpu" | "cpu"`) engineModel = flag.String("engine-model-name", "", `value of the engine's `+"`model_name`"+` Prometheus label to filter /metrics by (e.g. "Qwen/Qwen2.5-0.5B-Instruct"). Distinct from --model-id (the cache-plane index key). Empty = no label filter (aggregates every series — fine when the engine serves one model).`) ignoreBlockRemoved = flag.Bool("ignore-block-removed", false, `declare that this engine is paired with an L2 offload tier (e.g. LMCache): a BlockRemoved is re-reported as a T2 (reload-from-L2) entry instead of a PREFIX_EVICTED delete, so the routing hint survives the HBM eviction but is honestly tagged colder. Default off for single-tier deployments (BlockRemoved → PREFIX_EVICTED). Name kept for compatibility. See docs/design/kvevent-subscriber-wiring.md "L2 cache tier semantics".`) - adapterNames = flag.String("lora-adapter-names", "", `comma-separated `+"`id=name`"+` map from the engine's internal LoRA id (BlockStored.lora_id, assigned in --lora-modules load order) to the stable adapter identity used as the index partition — the same string the gateway sends as LookupRouteRequest.adapter_id (e.g. "1=sql-lora,2=chat-lora"). Unmapped ids fall back to "lora:". Omit ONLY for base-model / non-LoRA traffic (nil lora_id → the default "" partition). ANY LoRA adapter — even a single one — carries a non-nil lora_id that never maps to "", so the ingest identifier here MUST agree end-to-end with the gateway's LookupRoute query identifier or every lookup silently misses: either configure the mapping (both sides use the name) or deliberately use the "lora:" fallback on both sides.`) + adapterNames = flag.String("lora-adapter-names", "", `comma-separated `+"`id=name`"+` map from the engine's internal LoRA id (BlockStored.lora_id, assigned in --lora-modules load order) to the stable adapter identity used as the index partition — the same string the gateway sends as LookupRouteRequest.adapter_id (e.g. "1=sql-lora,2=chat-lora"). An unmapped non-nil lora_id is FAIL-CLOSED: its blocks are dropped (not cached) rather than indexed under a replica-local "lora:" that could alias different adapters across replicas — so LoRA caching REQUIRES this map. Omit ONLY for base-model / non-LoRA traffic (nil lora_id → the default "" partition). The identity mapped here MUST match the gateway's LookupRoute query adapter_id end-to-end or every lookup silently misses (the same agreement --hash-scheme requires).`) ) flag.Parse() diff --git a/docs/design/grpc-contract.md b/docs/design/grpc-contract.md index 3ce927fc..f54ca17b 100644 --- a/docs/design/grpc-contract.md +++ b/docs/design/grpc-contract.md @@ -270,8 +270,8 @@ The fix partitions the **index**, not the hash: - `LookupRouteResponse.adapter_id` (5) echoes the partition the index was consulted in, so a caller can confirm the round trip. It is empty on the fail-open envelopes returned *before* the index is consulted (`TIMEOUT`, tokenizer fail-open, `POLICY_REQUIRES_CHAIN`). - `CacheEvent.adapter_id` (7) narrows a `PREFIX_EVICTED` to one partition. Without it, one adapter's GPU eviction would drop the identical hash under *every* adapter — discarding hints that are still valid. Empty keeps the original cross-partition sweep. -**Producer / consumer must agree, exactly as they already do for `hash_scheme`.** A lookup that sets `adapter_id` will not match entries ingested without one, and vice versa. Empty/unset is the **default partition**, and this is exactly the pre-adapter behavior **for base-model / non-LoRA traffic**: an engine serving no LoRA emits a nil `lora_id`, so ingest and lookup both land under `""` and are completely unaffected — no regression, no configuration required. **This is not a "single LoRA adapter just works" claim.** Only a *nil* `lora_id` uses `""`; *any* non-nil `lora_id` — including a deployment with exactly one adapter — resolves to a configured name or the `lora:` fallback and never `""`. So even one LoRA adapter requires the ingest identifier (the subscriber's resolved `adapter_id`) to match the gateway's query `adapter_id` end-to-end, or every lookup silently misses — the same agreement `hash_scheme` already demands. +**Producer / consumer must agree, exactly as they already do for `hash_scheme`.** A lookup that sets `adapter_id` will not match entries ingested without one, and vice versa. Empty/unset is the **default partition**, and this is exactly the pre-adapter behavior **for base-model / non-LoRA traffic**: an engine serving no LoRA emits a nil `lora_id`, so ingest and lookup both land under `""` and are completely unaffected — no regression, no configuration required. **This is not a "single LoRA adapter just works" claim.** Only a *nil* `lora_id` uses `""`; *any* non-nil `lora_id` — including a deployment with exactly one adapter — must resolve to a **configured** `--lora-adapter-names` identity, otherwise it is dropped at ingest (**fail-closed**) rather than landing under `""` or a replica-local alias. So even one LoRA adapter requires the ingest identifier (the subscriber's resolved `adapter_id`) to match the gateway's query `adapter_id` end-to-end, or every lookup silently misses — the same agreement `hash_scheme` already demands. -**Adapter identity is a stable string, not the engine's integer.** vLLM's `lora_id` is an internal load-order integer, so the *same* integer can mean different adapters on two replicas whose `--lora-modules` order differs — and the index is shared across replicas. The `kvevent-subscriber` therefore maps it to a stable identity via `--lora-adapter-names` (`id=name`, e.g. `1=sql-lora,2=chat-lora`), the same string the gateway sends as `adapter_id`. An unmapped id falls back to `lora:`, which is exact within a replica and agrees across replicas that share an adapter load order (the homogeneous-Deployment case). +**Adapter identity is a stable string, not the engine's integer.** vLLM's `lora_id` is an internal load-order integer, so the *same* integer can mean different adapters on two replicas whose `--lora-modules` order differs — and the index is shared across replicas. The `kvevent-subscriber` therefore maps it to a stable identity via `--lora-adapter-names` (`id=name`, e.g. `1=sql-lora,2=chat-lora`), the same string the gateway sends as `adapter_id`. An unmapped id is **fail-closed** — its blocks are dropped at ingest rather than partitioned under a replica-local `lora:` that could alias different adapters across replicas — so LoRA caching requires the map. **Diagnostics are deliberately adapter-blind.** The `(tenant, model, hash_scheme)` *scope* — which backs the distinguishing-power denominator, the `TENANT_HOT` serving check, the `AFFINITY_HINT` candidate set, and the `UNKNOWN_HASH_SCHEME` classifier — does **not** gain `adapter_id`. Those surfaces answer a replica-membership question and carry no per-adapter cache-content claim (`TENANT_HOT` / `AFFINITY_HINT` ship `matched_tokens=0` by contract), and adapters load and unload under a live engine, so adapter residency is a property of individual KV entries rather than of a replica's membership in an engine domain. Consequence: a lookup for an unseen adapter in a *known* engine domain is a novel-prefix miss (`NO_HINT` / `AFFINITY_HINT`), not `UNKNOWN_HASH_SCHEME` — that code keeps meaning "wrong `hash_scheme`". diff --git a/docs/design/kvevent-subscriber-wiring.md b/docs/design/kvevent-subscriber-wiring.md index a2c27203..950d7885 100644 --- a/docs/design/kvevent-subscriber-wiring.md +++ b/docs/design/kvevent-subscriber-wiring.md @@ -211,35 +211,39 @@ stable identity the gateway sends as `LookupRouteRequest.adapter_id`: --lora-adapter-names "1=sql-lora,2=chat-lora" ``` -- **Unmapped id** → `lora:`. Exact within a replica, and consistent across - replicas that share an adapter load order (the homogeneous-Deployment case). - Supply the map for adapters **known at startup** when replicas can diverge - (rolling updates, heterogeneous load order), and make the gateway send the - matching `adapter_id`. +- **Unmapped id** → **dropped (fail-closed)**. A non-nil `lora_id` with no + mapping has only a replica-local load-order integer, which could alias + different adapters across replicas whose load order differs — so its blocks are + dropped (not indexed) rather than partitioned under a hazardous `lora:`. + The adapter gets no routing hint (a cache miss, never a wrong replica) until it + is mapped; supply the map — for adapters **known at startup** — and make the + gateway send the matching `adapter_id`. - **No LoRA at all** (the default): every event carries a nil `lora_id`, so every entry and eviction lands in the default (`""`) partition — byte-for-byte the pre-adapter behavior. Nothing to configure. ### Limitations of the static flag -`--lora-adapter-names` is resolved once at **startup**, which leaves two gaps — -both a tracked follow-up, and neither a regression (an unmapped id still -partitions by `lora:`, strictly fewer collisions than the pre-adapter -single-partition behavior): +`--lora-adapter-names` is resolved once at **startup**, so an adapter it does not +name is **fail-closed** — its blocks are dropped (uncached), never aliased. That +trades cache benefit for safety in two cases, both a tracked follow-up: - **Runtime loads.** An adapter loaded or reloaded *after* the subscriber starts - whose id isn't already in the map falls back to `lora:` — a static list - can't name it. Covering arbitrary runtime loads needs the subscriber to - resolve adapter identity **dynamically** from the engine's adapter registry. + whose id isn't in the map is dropped — a static list can't name it. Covering + arbitrary runtime loads needs the subscriber to resolve adapter identity + **dynamically** from the engine's adapter registry. - **Managed injection.** On the `CacheBackend`-driven path the subscriber sidecar is **injected**, so operators cannot pass the flag on a container they do not define; the explicit-set guidance below applies only to a **self-managed** subscriber. Configuring the mapping through the managed path needs a `CacheBackend` field the webhook forwards. -For a self-managed subscriber, set `--lora-adapter-names` on the container -directly: the controller has no view of the engine's `--lora-modules` ordering, -so multi-adapter engines must supply the map explicitly. +Neither is a correctness regression — a dropped adapter gets no routing hint (a +cache miss, never a wrong replica), strictly safer than the alias a replica-local +`lora:` fallback would risk. For a self-managed subscriber, set +`--lora-adapter-names` on the container directly: the controller has no view of +the engine's `--lora-modules` ordering, so multi-adapter engines must supply the +map explicitly. ## What this unblocks diff --git a/pkg/adapters/engine/config.go b/pkg/adapters/engine/config.go index 8bde427b..9bd5441b 100644 --- a/pkg/adapters/engine/config.go +++ b/pkg/adapters/engine/config.go @@ -34,47 +34,53 @@ type Config struct { // the aliasing this partition exists to prevent — across replicas instead of // within one. // - // Optional. An id with no mapping falls back to "lora:", which is exact - // within a replica and agrees ACROSS replicas whenever they share the same - // adapter load order (the homogeneous-Deployment case). Supply the map — from - // the same --lora-modules ordering the engine gets — for deployments whose - // replicas can diverge (runtime load/unload, rolling updates), and supply the - // matching adapter_id from the gateway. + // Only a NIL lora_id (base model / no LoRA) uses the default ("") partition, + // and that path needs no configuration. A non-nil lora_id with NO mapping is + // FAIL-CLOSED at ingest — AdapterID reports ok=false and the subscriber drops + // the event rather than partitioning it under a replica-local "lora:", + // which could place different adapters in one partition across replicas whose + // --lora-modules order differs (the very cross-replica alias this partition + // exists to prevent). // - // Only a NIL lora_id (base model / no LoRA) uses the default ("") partition. - // A non-nil lora_id — INCLUDING a single-adapter deployment — always resolves - // to a configured name or the "lora:" fallback, never "". So a LoRA - // deployment does not "just work": whatever identity is ingested here MUST - // match the adapter_id the gateway queries with, or every lookup silently - // misses (same producer/consumer agreement HashScheme requires). Nil/empty - // AdapterNames is correct only for base-model / non-LoRA traffic, or when - // both sides deliberately rely on the "lora:" fallback. + // So LoRA caching REQUIRES this map: supply it from the same --lora-modules + // ordering the engine gets, and send the matching adapter_id from the gateway + // (whatever identity is ingested here MUST match the gateway's query id — the + // same producer/consumer agreement HashScheme requires). An unmapped adapter + // is neither aliased nor cached — its prefixes get no hint (a miss, never a + // wrong replica) until it is mapped. Nil/empty AdapterNames is correct only + // for base-model / non-LoRA traffic. AdapterNames map[int64]string } // AdapterID resolves an engine LoRA id to the stable adapter identity that -// partitions the index. A nil id (base model / no adapter) yields "" — the -// default partition, which is the behavior for every non-LoRA deployment. -// A mapped id yields its configured name; an unmapped one yields "lora:" -// (see AdapterNames for why that fallback is exact per-replica but only -// load-order-stable across replicas). -func (c Config) AdapterID(loraID *int64) string { +// partitions the index, and reports whether that id is safe to ingest: +// +// - a nil id (base model / no adapter) → ("", true): the default partition, +// the behavior for every non-LoRA deployment; +// - a mapped id → (name, true): its configured stable identity; +// - an unmapped non-nil id → ("", false): FAIL CLOSED. The engine's id is a +// replica-local load-order integer with no globally stable meaning, so +// indexing it under "lora:" could place different adapters in the same +// partition on replicas whose --lora-modules order differs — the very +// cross-replica alias this partition exists to prevent. The caller drops the +// ingest; the adapter is cached only once --lora-adapter-names maps its id. +func (c Config) AdapterID(loraID *int64) (string, bool) { if loraID == nil { - return "" + return "", true } if name, ok := c.AdapterNames[*loraID]; ok && name != "" { - return name + return name, true } - return "lora:" + strconv.FormatInt(*loraID, 10) + return "", false } // ParseAdapterNames parses a comma-separated "id=name" list into the // Config.AdapterNames map (e.g. "1=sql-lora,2=chat-lora"). Empty input yields a -// nil map — no mapping, so every adapter id falls back to "lora:". Blank -// list entries are skipped so a trailing comma is tolerated; a malformed pair, -// a non-integer id, an empty name, or a duplicate id is an error, because -// silently dropping one would put that adapter's blocks in a different partition -// than the gateway looks up and turn every one of its lookups into a miss. +// nil map — no mapping, so every non-nil lora_id is fail-closed (dropped, not +// cached) at ingest until it is mapped. Blank list entries are skipped so a +// trailing comma is tolerated; a malformed pair, a non-integer id, an empty +// name, or a duplicate id is an error, because silently dropping one would leave +// that adapter unmapped — uncached where the operator asked for caching. func ParseAdapterNames(s string) (map[int64]string, error) { var out map[int64]string for _, pair := range strings.Split(s, ",") { diff --git a/pkg/adapters/engine/forwarder.go b/pkg/adapters/engine/forwarder.go index 1dccae47..a9b6dc85 100644 --- a/pkg/adapters/engine/forwarder.go +++ b/pkg/adapters/engine/forwarder.go @@ -42,6 +42,10 @@ type Reporter struct { // pos derives our positional content fingerprint from each event's tokens and // chains it across events. Owned by Run (single goroutine), so unsynchronized. pos *positionalIndex + // warnedAdapters remembers which unmapped LoRA ids we've already logged a + // fail-closed drop for, so a hot adapter warns once rather than per event. + // Owned by Run (single goroutine), so unsynchronized. + warnedAdapters map[int64]bool } // ReporterOption configures a Reporter. @@ -79,13 +83,14 @@ func WithIgnoreBlockRemoved(b bool) ReporterOption { // NewReporter builds a Reporter for one engine replica. func NewReporter(client icpb.InferenceCacheClient, cfg Config, opts ...ReporterOption) *Reporter { r := &Reporter{ - client: client, - cfg: cfg, - window: 100 * time.Millisecond, - rpcTimeout: 5 * time.Second, - maxPend: 4096, - logger: slog.Default(), - pos: newPositionalIndex(), + client: client, + cfg: cfg, + window: 100 * time.Millisecond, + rpcTimeout: 5 * time.Second, + maxPend: 4096, + logger: slog.Default(), + pos: newPositionalIndex(), + warnedAdapters: make(map[int64]bool), } for _, o := range opts { o(r) @@ -133,10 +138,20 @@ func (r *Reporter) Run(ctx context.Context, in <-chan *EventBatch) error { switch e := ev.(type) { case BlockStored: // Resolve the engine's internal LoRA id to the stable adapter - // identity that partitions the index (see Config.AdapterID). - // nil id → "" → the default partition, i.e. exactly the - // pre-adapter behavior for every non-LoRA deployment. - entries := r.pos.Stored(e, r.cfg.AdapterID(e.LoRAID)) + // identity that partitions the index (see Config.AdapterID). A + // nil id → "" → the default partition, exactly the pre-adapter + // behavior for every non-LoRA deployment. A non-nil id with no + // --lora-adapter-names mapping is FAIL-CLOSED: its only identity + // is a replica-local load-order integer that could alias + // different adapters across replicas, so drop the event rather + // than index it under a hazardous partition (warned once per id). + // The adapter is cached once its id is mapped. + adapterID, ok := r.cfg.AdapterID(e.LoRAID) + if !ok { + r.warnUnmappedAdapter(e.LoRAID) + continue + } + entries := r.pos.Stored(e, adapterID) if len(entries) == 0 && len(e.BlockHashes) > 0 { // A BlockStored carrying block hashes produced no index // entries — either token_ids are absent (engine not emitting @@ -239,6 +254,22 @@ func (r *Reporter) sendAdds(prefixes []*icpb.PrefixEntry, tsUs int64) { } } +// warnUnmappedAdapter logs, once per LoRA id, that a BlockStored was dropped +// because the id has no --lora-adapter-names mapping (fail-closed — see +// Config.AdapterID). Run is single-goroutine, so warnedAdapters needs no lock. +func (r *Reporter) warnUnmappedAdapter(loraID *int64) { + if loraID == nil { + return + } + id := *loraID + if r.warnedAdapters[id] { + return + } + r.warnedAdapters[id] = true + r.logger.Warn("dropping BlockStored for an unmapped LoRA id (fail-closed; add it to --lora-adapter-names to cache this adapter)", + "lora_id", id) +} + // publish sends a single CacheEvent via a time-bounded unary PublishEvent. // Best-effort: failures are logged and dropped. func (r *Reporter) publish(ev *icpb.CacheEvent) { diff --git a/pkg/adapters/engine/lora_adapter_test.go b/pkg/adapters/engine/lora_adapter_test.go index 7f396171..f469de56 100644 --- a/pkg/adapters/engine/lora_adapter_test.go +++ b/pkg/adapters/engine/lora_adapter_test.go @@ -70,20 +70,20 @@ func TestConfigAdapterID(t *testing.T) { cfg := testConfig() cfg.AdapterNames = map[int64]string{1: "sql-lora"} - if got := cfg.AdapterID(nil); got != "" { - t.Errorf("AdapterID(nil) = %q, want \"\" (base model → default partition)", got) + if got, ok := cfg.AdapterID(nil); got != "" || !ok { + t.Errorf("AdapterID(nil) = (%q, %v), want (\"\", true) — base model → default partition", got, ok) } one, two := int64(1), int64(2) - if got := cfg.AdapterID(&one); got != "sql-lora" { - t.Errorf("AdapterID(1) = %q, want sql-lora", got) + if got, ok := cfg.AdapterID(&one); got != "sql-lora" || !ok { + t.Errorf("AdapterID(1) = (%q, %v), want (sql-lora, true)", got, ok) } - // Unmapped ids stay exact within a replica via the "lora:" fallback — - // they must never collapse to "" (which would alias with the base model). - if got := cfg.AdapterID(&two); got != "lora:2" { - t.Errorf("AdapterID(2) = %q, want lora:2", got) + // An unmapped non-nil id is fail-closed: ok=false so the caller drops the + // event rather than indexing it under an alias-prone "lora:". + if got, ok := cfg.AdapterID(&two); got != "" || ok { + t.Errorf("AdapterID(2) = (%q, %v), want (\"\", false) — unmapped → fail closed", got, ok) } - if got := (Config{}).AdapterID(&one); got != "lora:1" { - t.Errorf("AdapterID with no map = %q, want lora:1", got) + if got, ok := (Config{}).AdapterID(&one); got != "" || ok { + t.Errorf("AdapterID with no map = (%q, %v), want (\"\", false) — unmapped → fail closed", got, ok) } } @@ -146,9 +146,11 @@ func TestStoredSameTokensDifferentAdaptersShareHashNotPartition(t *testing.T) { } } -// End-to-end through the Reporter: one batch carrying two adapters' blocks is -// forwarded as entries stamped with each adapter's resolved identity, and the -// eviction that follows names the partition it came from. +// End-to-end through the Reporter: one batch carrying a mapped adapter's blocks, +// an UNMAPPED adapter's blocks, and base-model blocks. The mapped adapter and the +// base model are forwarded stamped with their resolved identity; the unmapped +// adapter is fail-closed (dropped, never indexed under a hazardous "lora:"). +// The eviction that follows names the partition it came from. func TestReporterPartitionsEntriesByLoRAID(t *testing.T) { const bs = 16 one, two := int64(1), int64(2) @@ -180,10 +182,12 @@ func TestReporterPartitionsEntriesByLoRAID(t *testing.T) { adapters = append(adapters, p.GetAdapterId()) } } - want := map[string]bool{"sql-lora": false, "lora:2": false, "": false} + // lora_id=2 is unmapped → fail-closed → dropped, so it must NOT appear (only + // the mapped sql-lora and the base "" partition are forwarded). + want := map[string]bool{"sql-lora": false, "": false} for _, a := range adapters { if _, known := want[a]; !known { - t.Errorf("unexpected adapter_id %q on a forwarded entry", a) + t.Errorf("unexpected adapter_id %q on a forwarded entry (unmapped lora_id=2 must be dropped, not partitioned)", a) continue } want[a] = true @@ -209,6 +213,38 @@ func TestReporterPartitionsEntriesByLoRAID(t *testing.T) { } } +// An unmapped non-nil lora_id is fail-closed: with no --lora-adapter-names entry +// its blocks are dropped rather than indexed under a replica-local "lora:" +// that could alias adapters across replicas. Nothing is forwarded — not the add, +// and not an eviction for a block that was never indexed. +func TestReporterFailsClosedOnUnmappedAdapter(t *testing.T) { + const bs = 16 + seven := int64(7) + cfg := testConfig() // no AdapterNames → every non-nil lora_id is unmapped + + rec := runReporterCfg(t, cfg, nil, + &EventBatch{TimestampSeconds: 2.0, Events: []Event{ + BlockStored{BlockHashes: [][]byte{{0x0a}}, TokenIDs: tokSeq(1, bs), BlockSize: bs, LoRAID: &seven}, + }}, + &EventBatch{TimestampSeconds: 3.0, Events: []Event{ + BlockRemoved{BlockHashes: [][]byte{{0x0a}}}, + }}, + ) + + rec.mu.Lock() + defer rec.mu.Unlock() + for _, u := range rec.updates { + if n := len(u.GetPrefixes()); n != 0 { + t.Errorf("forwarded %d prefixes for an unmapped adapter; want 0 (fail-closed)", n) + } + } + for _, ev := range rec.events { + if ev.GetPrefixHash() != nil { + t.Errorf("forwarded a PREFIX_EVICTED (%x) for an unmapped adapter; want none — its block was never indexed", ev.GetPrefixHash()) + } + } +} + // A deployment with no LoRA at all is byte-for-byte what it was before: every // event has a nil lora_id, so every entry and eviction stays in the default // partition. diff --git a/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go b/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go index 5bb143d0..c4f330e7 100644 --- a/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go +++ b/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go @@ -381,12 +381,13 @@ type LookupRouteRequest struct { // serving no LoRA emits a nil lora_id, so ingest and lookup both land under "" // and are unaffected. This is NOT a "single LoRA adapter just works" claim: // only a nil lora_id uses ""; ANY LoRA adapter (even one) resolves to a - // configured name or the "lora:" fallback and never "". A lookup that sets - // adapter_id will NOT match entries ingested without one (and vice versa), so - // even one adapter requires the ingest identifier to agree with the query - // adapter_id end-to-end or every lookup silently misses — the same rule - // hash_scheme already follows. The server treats the value as an opaque string - // and never interprets it. Additive, v1alpha1-compatible. + // configured --lora-adapter-names identity — an unmapped adapter is dropped at + // ingest (fail-closed), never indexed under "" or a replica-local alias. A + // lookup that sets adapter_id will NOT match entries ingested without one (and + // vice versa), so even one adapter requires the ingest identifier to agree with + // the query adapter_id end-to-end or every lookup silently misses — the same + // rule hash_scheme already follows. The server treats the value as an opaque + // string and never interprets it. Additive, v1alpha1-compatible. AdapterId string `protobuf:"bytes,11,opt,name=adapter_id,json=adapterId,proto3" json:"adapter_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/proto/inferencecache/v1alpha1/inferencecache.proto b/proto/inferencecache/v1alpha1/inferencecache.proto index 316f0ad2..8e7e5fa5 100644 --- a/proto/inferencecache/v1alpha1/inferencecache.proto +++ b/proto/inferencecache/v1alpha1/inferencecache.proto @@ -108,12 +108,13 @@ message LookupRouteRequest { // serving no LoRA emits a nil lora_id, so ingest and lookup both land under "" // and are unaffected. This is NOT a "single LoRA adapter just works" claim: // only a nil lora_id uses ""; ANY LoRA adapter (even one) resolves to a - // configured name or the "lora:" fallback and never "". A lookup that sets - // adapter_id will NOT match entries ingested without one (and vice versa), so - // even one adapter requires the ingest identifier to agree with the query - // adapter_id end-to-end or every lookup silently misses — the same rule - // hash_scheme already follows. The server treats the value as an opaque string - // and never interprets it. Additive, v1alpha1-compatible. + // configured --lora-adapter-names identity — an unmapped adapter is dropped at + // ingest (fail-closed), never indexed under "" or a replica-local alias. A + // lookup that sets adapter_id will NOT match entries ingested without one (and + // vice versa), so even one adapter requires the ingest identifier to agree with + // the query adapter_id end-to-end or every lookup silently misses — the same + // rule hash_scheme already follows. The server treats the value as an opaque + // string and never interprets it. Additive, v1alpha1-compatible. string adapter_id = 11; }