Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/design/grpc-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ service InferenceCache {
- **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; string adapter_id = 7; }`
- **CacheEvent** `{ Type type; string replica_id; string model_id; string tenant_id; bytes prefix_hash; int64 timestamp_us; string adapter_id = 7; bool adapter_scoped = 8; }`
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).
`adapter_id` narrows a `PREFIX_EVICTED` to one adapter partition **when `adapter_scoped` is set** (so `""` targets the base-model partition, not a sweep) **or when `adapter_id` is non-empty** (a pre-`adapter_scoped` producer naming a LoRA adapter — honored so a mixed-version fleet doesn't regress its narrowing). Only an empty `adapter_id` with `adapter_scoped` unset removes the prefix across **every** partition (the conservative legacy sweep — 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<string,string> 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; }`
Expand Down Expand Up @@ -268,7 +268,7 @@ The fix partitions the **index**, not the 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.
- `CacheEvent.adapter_id` (7) narrows a `PREFIX_EVICTED` to one partition, with the new `adapter_scoped` (8) flag distinguishing a base-model eviction from a legacy sweep. The removal scopes to `adapter_id` when `adapter_scoped` is set (so `adapter_id=""` drops only the base partition, no longer discarding another adapter's still-valid hint for the identical hash) **or** when `adapter_id` is non-empty (a pre-`adapter_scoped` producer that named a LoRA adapter — honored so a mixed-version fleet doesn't regress a `sql-lora` eviction into a cross-partition sweep). Only an empty `adapter_id` with `adapter_scoped` unset keeps the conservative cross-partition sweep. A separate bool rather than making `adapter_id` `optional`, which the wire-compat gate (`buf breaking`) rejects as a cardinality change — the bool is purely additive.

**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.

Expand Down
22 changes: 13 additions & 9 deletions pkg/adapters/engine/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,20 @@ 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) 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.
// positionalIndex.Removed. adapter_scoped is always set, so the server drops the
// prefix from exactly the adapterID partition (even "" for the base model) — a
// base-model eviction never sweeps live LoRA hints for the same token hash.
// (A producer that leaves adapter_scoped false gets the conservative
// cross-partition legacy sweep.)
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),
Type: icpb.CacheEvent_PREFIX_EVICTED,
ReplicaId: c.ReplicaID,
ModelId: c.ModelID,
TenantId: c.TenantID,
PrefixHash: prefixHash,
AdapterId: adapterID,
AdapterScoped: true,
TimestampUs: microsFromSeconds(tsSeconds),
}
}
14 changes: 10 additions & 4 deletions pkg/adapters/engine/mapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,23 @@ 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)
// A base-model eviction carries adapter_id="" but marks it authoritative
// (adapter_scoped), so the server drops only the base ("") partition instead
// of sweeping every adapter.
if got := e.GetAdapterId(); got != "" {
t.Errorf("adapter id = %q, want \"\" (base partition)", got)
}
if !e.GetAdapterScoped() {
t.Error("adapter_scoped = false; a base eviction must mark adapter_id authoritative to scope removal to the base partition")
}
}

// 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)
if e.GetAdapterId() != "sql-lora" || !e.GetAdapterScoped() {
t.Errorf("adapter id = %q scoped = %v, want sql-lora / true", e.GetAdapterId(), e.GetAdapterScoped())
}
}

Expand Down
47 changes: 46 additions & 1 deletion pkg/index/adapter_partition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ func TestAdapterPartitionEvictionIsScopedToItsAdapter(t *testing.T) {
idx.ApplyEvent(Event{
Type: EventPrefixEvicted, ReplicaID: "replica-0",
Model: adapterModel, Tenant: adapterTenant,
PrefixHash: hash("same-tokens"), Adapter: "sql-lora",
PrefixHash: hash("same-tokens"), Adapter: "sql-lora", AdapterScoped: true,
})

if got := lookupUnder(idx, "sql-lora", "same-tokens"); len(got) != 0 {
Expand All @@ -199,6 +199,51 @@ func TestAdapterPartitionEvictionIsScopedToItsAdapter(t *testing.T) {
}
}

// A base-model eviction (Adapter "" WITH AdapterScoped) drops ONLY the base
// partition and must not sweep a live LoRA hint for the same token hash — the
// over-sweep that the adapter_scoped flag exists to fix.
func TestAdapterPartitionBaseEvictionScopedSparesLoRA(t *testing.T) {
idx := New()
ingestUnder(idx, "replica-0", "", "same-tokens")
ingestUnder(idx, "replica-0", "sql-lora", "same-tokens")

idx.ApplyEvent(Event{
Type: EventPrefixEvicted, ReplicaID: "replica-0",
Model: adapterModel, Tenant: adapterTenant,
PrefixHash: hash("same-tokens"), Adapter: "", AdapterScoped: true,
})

if got := lookupUnder(idx, "", "same-tokens"); len(got) != 0 {
t.Errorf("base entry survived its own eviction: %+v", got)
}
if got := lookupUnder(idx, "sql-lora", "same-tokens"); len(got) != 1 {
t.Errorf("sql-lora entry = %+v, want it untouched by the base-model eviction", got)
}
}

// Upgrade compatibility: a pre-adapter_scoped producer (new server, not-yet-
// upgraded subscriber) sends a non-empty adapter_id but leaves adapter_scoped
// false. Its LoRA eviction must still narrow to that adapter, not regress into a
// cross-partition sweep that wipes another adapter's live hint.
func TestAdapterPartitionEvictionNonEmptyAdapterScopesWithoutFlag(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", // AdapterScoped left false (pre-upgrade producer)
})

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 — a non-empty adapter_id must narrow even without adapter_scoped", 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
Expand Down
43 changes: 24 additions & 19 deletions pkg/index/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,14 +243,18 @@ type Event struct {
Model string
Tenant string
PrefixHash []byte
// 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
// Adapter narrows a PREFIX_EVICTED removal to one adapter partition. The removal
// scopes to Adapter when AdapterScoped is set (the authoritative signal — so
// Adapter="" targets the base partition, not a sweep) OR when Adapter is
// non-empty (a pre-adapter_scoped producer naming a LoRA adapter; honored so a
// mixed-version fleet doesn't regress its narrowing into a sweep). It sweeps
// EVERY adapter partition only when Adapter is empty AND AdapterScoped is unset
// — the conservative legacy behavior for a no-adapter producer. Both are ignored
// by ALL_CLEARED (a flush clears the replica across adapters) and by
// REPLICA_UPDATED (liveness is adapter-independent).
Adapter string
Timestamp time.Time
Adapter string
AdapterScoped bool
Timestamp time.Time
}

// LookupRequest asks which replicas hold a given prefix, within a hash scheme.
Expand Down Expand Up @@ -898,22 +902,23 @@ func (i *Index) ApplyEvent(ev Event) {
// 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.
// The removal narrows to ev.Adapter unless it genuinely cannot tell which
// partition to target. It narrows when EITHER:
// - the producer set adapter_scoped — the new authoritative signal, so
// adapter_id="" means the base partition, not a sweep; OR
// - adapter_id is non-empty — a pre-adapter_scoped producer that named a
// LoRA adapter. Honoring that keeps its narrowing on a mixed-version
// fleet (new server + not-yet-upgraded subscriber) rather than
// regressing a "sql-lora" eviction into a cross-partition sweep.
// It sweeps EVERY partition only when adapter_id is empty AND adapter_scoped
// is unset — a legacy no-adapter producer, whose entries all live in the ""
// partition anyway, so the sweep is exact for it.
scoped := ev.AdapterScoped || ev.Adapter != ""
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 {
if scoped && key.adapter != ev.Adapter {
continue
}
i.removeReplicaLocked(key, replicas, ev.ReplicaID)
Expand Down
11 changes: 7 additions & 4 deletions pkg/server/inferencecache_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1034,10 +1034,13 @@ func (s *inferenceCacheService) PublishEvent(_ context.Context, ev *icpb.CacheEv
Model: ev.GetModelId(),
Tenant: ev.GetTenantId(),
PrefixHash: ev.GetPrefixHash(),
// Narrows a PREFIX_EVICTED to one adapter partition; empty keeps the
// conservative cross-partition removal (legacy producers).
Adapter: ev.GetAdapterId(),
Timestamp: microsToTime(ev.GetTimestampUs()),
// Narrows a PREFIX_EVICTED to one adapter partition when the producer
// marked adapter_id authoritative (adapter_scoped) — including "" for a
// base-model eviction. Unset keeps the conservative cross-partition
// removal (legacy producers).
Adapter: ev.GetAdapterId(),
AdapterScoped: ev.GetAdapterScoped(),
Timestamp: microsToTime(ev.GetTimestampUs()),
})
}
return &icpb.Ack{Accepted: true}, nil
Expand Down
Loading
Loading