diff --git a/cmd/kvevent-subscriber/main.go b/cmd/kvevent-subscriber/main.go index 3be3abe5..3e1f211b 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, `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"). 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() 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 a60cebd6..f54ca17b 100644 --- a/docs/design/grpc-contract.md +++ b/docs/design/grpc-contract.md @@ -44,8 +44,10 @@ 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; }` + `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; }` @@ -53,14 +55,16 @@ 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* 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; }` -- **PrefixEntry** `{ bytes prefix_hash = 1; int32 token_count = 2; repeated bytes block_hashes = 3; repeated int32 block_token_counts = 4; CacheTier tier = 5; }` — **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. +- **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, 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. `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; }` @@ -246,7 +250,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: @@ -255,3 +259,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` (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`). +- `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 — 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 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 af5ec5af..950d7885 100644 --- a/docs/design/kvevent-subscriber-wiring.md +++ b/docs/design/kvevent-subscriber-wiring.md @@ -192,6 +192,59 @@ Other adapters (e.g. plain vLLM, or future runtimes with no L2 tier) leave the flag off for the same reason as `EventsOnly` — their stored prefixes stay T1 and a `BlockRemoved` deletes them. +## 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** → **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**, 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 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. + +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 * 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..7ad6d852 100755 --- a/docs/reference-stack/scripts/default_install_smoke.sh +++ b/docs/reference-stack/scripts/default_install_smoke.sh @@ -1340,6 +1340,118 @@ 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 + +# 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 +} + +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 # resolver → buildLookupResponse downgrade. The same 64-token prefix that @@ -3643,4 +3755,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..9bd5441b 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,93 @@ 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. + // + // 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). + // + // 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, 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 "", true + } + if name, ok := c.AdapterNames[*loraID]; ok && name != "" { + return name, true + } + 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 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, ",") { + 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 6dc087c4..a9b6dc85 100644 --- a/pkg/adapters/engine/forwarder.go +++ b/pkg/adapters/engine/forwarder.go @@ -17,7 +17,8 @@ import ( // L2 offload tier (WithIgnoreBlockRemoved): with one, the block moved tiers, so it // is re-reported at T2 through the same ReportCacheState add path; without one it // is gone, forwarded as PREFIX_EVICTED via a time-bounded unary PublishEvent. -// AllBlocksCleared → ALL_CLEARED, also via PublishEvent. +// AllBlocksCleared → ALL_CLEARED, also via PublishEvent. Adds, T2 downgrades, and +// evictions all carry the entry's resolved adapter partition (see positionalIndex). // // Every RPC uses its own bounded context, so a stalled or unreachable server can // never block the loop for longer than rpcTimeout — the cache is an optimization @@ -41,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. @@ -78,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) @@ -131,7 +137,21 @@ 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). 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 @@ -154,15 +174,16 @@ func (r *Reporter) Run(ctx context.Context, in <-chan *EventBatch) error { // engine never references an HBM-evicted block as a future parent, // so retaining it only grows pos unbounded — and in L2 mode there // is no other prune point until AllBlocksCleared. Resolve the keys - // (which also forgets them) before deciding how to report. + // (which also forgets them, and carries each block's adapter + // partition) before deciding how to report. evicted := r.pos.Removed(e) if r.l2TierPresent { // L2 tier present: the block moved from HBM to the offload tier, // it is not gone. Re-report each evicted prefix at T2 (reload-able - // from L2) instead of deleting it — the index applies - // last-write-wins on tier, moving the entry T1→T2; a later - // BlockStored re-store re-reports it at T1 (upgrade). Nothing to - // do when the block wasn't ours. + // from L2) in its own adapter partition instead of deleting it — + // the index applies last-write-wins on tier, moving the entry + // T1→T2; a later BlockStored re-store re-reports it at T1 + // (upgrade). Nothing to do when the block wasn't ours. if len(evicted) == 0 { continue } @@ -181,8 +202,9 @@ func (r *Reporter) Run(ctx context.Context, in <-chan *EventBatch) error { downgrades := make([]*icpb.PrefixEntry, 0, len(evicted)) for _, ep := range evicted { downgrades = append(downgrades, &icpb.PrefixEntry{ - PrefixHash: ep.prefixHash, - TokenCount: ep.tokenCount, + PrefixHash: ep.PrefixHash, + TokenCount: ep.TokenCount, + AdapterId: ep.AdapterID, Tier: icpb.CacheTier_CACHE_TIER_T2, }) } @@ -196,7 +218,7 @@ func (r *Reporter) Run(ctx context.Context, in <-chan *EventBatch) error { // store→evict order. flush() for _, ep := range evicted { - r.publish(r.cfg.EvictedEvent(ep.prefixHash, b.TimestampSeconds)) + r.publish(r.cfg.EvictedEvent(ep.PrefixHash, ep.AdapterID, b.TimestampSeconds)) } case AllBlocksCleared: pending = pending[:0] // a clear supersedes buffered adds @@ -232,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 new file mode 100644 index 00000000..f469de56 --- /dev/null +++ b/pkg/adapters/engine/lora_adapter_test.go @@ -0,0 +1,276 @@ +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, 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, ok := cfg.AdapterID(&one); got != "sql-lora" || !ok { + t.Errorf("AdapterID(1) = (%q, %v), want (sql-lora, true)", got, ok) + } + // 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, ok := (Config{}).AdapterID(&one); got != "" || ok { + t.Errorf("AdapterID with no map = (%q, %v), want (\"\", false) — unmapped → fail closed", got, ok) + } +} + +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 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) + 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()) + } + } + // 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 (unmapped lora_id=2 must be dropped, not partitioned)", 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) + } +} + +// 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. +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 55d40685..dbf730ec 100644 --- a/pkg/adapters/engine/positional.go +++ b/pkg/adapters/engine/positional.go @@ -27,6 +27,24 @@ 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 the engine evicted, resolved back to the entry +// we derived for it: our content-fingerprint prefix hash, the adapter partition +// it lives in, and its cumulative token count. The adapter scopes the eviction to +// the right partition (the fingerprint is token-only, so one prefix hash can be +// live under several adapters at once); the token count lets the caller re-report +// the prefix at a colder tier (T2) when a paired L2 store still holds it, instead +// of only being able to delete it. +type EvictedPrefix struct { + PrefixHash []byte + AdapterID string + TokenCount int32 } func newPositionalIndex() *positionalIndex { @@ -46,7 +64,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,6 +111,7 @@ func (p *positionalIndex) Stored(ev BlockStored) []*icpb.PrefixEntry { out = append(out, &icpb.PrefixEntry{ PrefixHash: fingerprint.Bytes(phs[i]), TokenCount: tokens, + AdapterId: adapterID, // A stored block is resident in the engine KV cache — tier T1. Stamped // explicitly so the wire is self-describing; the server would default // an unset tier to T1 anyway, but being explicit keeps a captured @@ -85,33 +119,39 @@ func (p *positionalIndex) Stored(ev BlockStored) []*icpb.PrefixEntry { // reporter emits on eviction (see forwarder.go BlockRemoved). Tier: icpb.CacheTier_CACHE_TIER_T1, }) - 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 } -// evictedPrefix is one block the engine evicted, resolved back to the index key -// we derived for it (our content fingerprint) plus its cumulative token count. -// The token count lets the caller re-report the prefix at a colder tier (T2) when -// a paired L2 store still holds it, instead of only being able to delete it. -type evictedPrefix struct { - prefixHash []byte - tokenCount int32 -} - -// Removed maps each evicted engine block hash back to the index entry we derived -// for it (prefix hash + token count) and forgets it. Unknown hashes are skipped. -// The caller either forwards each as a PREFIX_EVICTED (single-tier: the block is -// gone) or re-reports it at T2 (L2 tier present: the block moved tiers, not gone). -func (p *positionalIndex) Removed(ev BlockRemoved) []evictedPrefix { +// Removed maps each evicted engine block hash back to the entry we derived for it +// — prefix hash (the index key), the adapter partition it was stored under, and +// its cumulative token count — and forgets it. Unknown hashes are skipped. The +// caller either forwards each as a PREFIX_EVICTED (single-tier: the block is gone) +// or re-reports it at T2 (L2 tier present: the block moved tiers, not gone), in +// both cases scoped to the returned adapter partition. +// +// 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 (or downgrade) 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([]evictedPrefix, 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, evictedPrefix{prefixHash: fingerprint.Bytes(pe.prefixHash), tokenCount: pe.tokenCount}) + out = append(out, EvictedPrefix{ + PrefixHash: fingerprint.Bytes(pe.prefixHash), + AdapterID: pe.adapter, + TokenCount: pe.tokenCount, + }) delete(p.blocks, key) } } diff --git a/pkg/adapters/engine/positional_test.go b/pkg/adapters/engine/positional_test.go index 8ce816c0..4a169227 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,17 +100,20 @@ 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].prefixHash) != 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) + } // The token count is carried back so the caller can re-report the prefix at // T2 (L2 mode) rather than only being able to delete it. One full block. - if rm[0].tokenCount != bs { - t.Fatalf("Removed token count = %d, want %d", rm[0].tokenCount, bs) + if rm[0].TokenCount != bs { + t.Fatalf("Removed token count = %d, want %d", rm[0].TokenCount, bs) } // Re-removing the now-forgotten hash yields nothing. if rm2 := p.Removed(BlockRemoved{BlockHashes: [][]byte{engHash(5)}}); len(rm2) != 0 { @@ -124,7 +127,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{ @@ -132,7 +135,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 ab3ddfe9..e227cc7c 100644 --- a/pkg/index/index.go +++ b/pkg/index/index.go @@ -199,6 +199,13 @@ type PrefixRef struct { // the engine evicts while a paired L2 tier (LMCache) retains it is re-reported // at T2. 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). @@ -207,9 +214,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. @@ -231,7 +243,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. @@ -245,9 +264,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 non-LoRA behavior is unchanged. + Adapter string PrefixHash []byte TokenCount int32 BlockHashes [][]byte @@ -425,10 +451,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 } @@ -450,6 +488,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 @@ -529,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 @@ -728,6 +780,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 @@ -745,7 +805,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 @@ -753,14 +813,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 { @@ -832,10 +892,30 @@ 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 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 } + if ev.Adapter != "" && key.adapter != ev.Adapter { + continue + } i.removeReplicaLocked(key, replicas, ev.ReplicaID) } case EventAllCleared: @@ -926,7 +1006,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) @@ -1100,7 +1180,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 { @@ -1629,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.) @@ -2393,6 +2473,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,10 +2559,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) } - 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 + } + 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/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/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/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 866c0e17..a283b49c 100644 --- a/pkg/server/inferencecache_service.go +++ b/pkg/server/inferencecache_service.go @@ -294,9 +294,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, @@ -614,6 +618,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 @@ -668,6 +675,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 @@ -1024,7 +1034,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 @@ -1046,7 +1059,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{ @@ -1054,6 +1072,7 @@ func updateFromProto(u *icpb.CacheStateUpdate) index.Update { TokenCount: p.GetTokenCount(), BlockHashes: p.GetBlockHashes(), BlockTokenCounts: p.GetBlockTokenCounts(), + Adapter: p.GetAdapterId(), Tier: cacheTierFromProto(p.GetTier()), }) } diff --git a/pkg/server/lmcache_offload_integration_test.go b/pkg/server/lmcache_offload_integration_test.go index bcd0d39c..f7b04527 100644 --- a/pkg/server/lmcache_offload_integration_test.go +++ b/pkg/server/lmcache_offload_integration_test.go @@ -33,15 +33,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 b61fce21..c4f330e7 100644 --- a/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go +++ b/pkg/server/proto/inferencecache/v1alpha1/inferencecache.pb.go @@ -367,8 +367,28 @@ 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 — 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 --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 } @@ -473,6 +493,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"` @@ -567,7 +594,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 } @@ -630,6 +665,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"` @@ -796,10 +838,17 @@ 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. - Tier CacheTier `protobuf:"varint,5,opt,name=tier,proto3,enum=inferencecache.v1alpha1.CacheTier" json:"tier,omitempty"` + // 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 + // 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,6,opt,name=adapter_id,json=adapterId,proto3" json:"adapter_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -869,6 +918,13 @@ func (x *PrefixEntry) GetTier() CacheTier { return CacheTier_CACHE_TIER_UNSPECIFIED } +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"` @@ -982,14 +1038,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 } @@ -1073,6 +1136,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"` @@ -1230,13 +1300,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 } @@ -1313,6 +1392,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"` @@ -1585,8 +1671,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, @@ -1609,247 +1695,256 @@ 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, 0xd8, - 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, 0x36, 0x0a, 0x04, 0x74, 0x69, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, + 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, 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, + 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, + 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, 0xf7, 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, + 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, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x61, 0x70, 0x74, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 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, 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, 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, + 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, 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, 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, - 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, + 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, 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, + 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, 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, 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, + 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, 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, 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, + 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, 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, + 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, 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, 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 2518dc32..8e7e5fa5 100644 --- a/proto/inferencecache/v1alpha1/inferencecache.proto +++ b/proto/inferencecache/v1alpha1/inferencecache.proto @@ -96,6 +96,26 @@ 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 — 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 --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; } // CacheTier ranks WHERE a replica holds a matched prefix, from the most local / @@ -141,6 +161,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 { @@ -179,10 +207,17 @@ 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 + // 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 = 6; } message ReplicaStats { @@ -224,6 +259,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 { @@ -255,6 +297,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 {