Skip to content

Commit e858c36

Browse files
committed
fix(kvcache): decode lora_id/medium/lora_name/group_idx in BlockStored (#2285)
1 parent f8a74b0 commit e858c36

3 files changed

Lines changed: 195 additions & 9 deletions

File tree

pkg/cache/kvcache/event_types.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ import (
2828
// None, # parent_block_hash
2929
// [55, 11, 22], # token_ids
3030
// 32, # block_size
31-
// None, # lora_id
32-
// "cuda:0" # medium
31+
// None, # lora_id (deprecated)
32+
// "GPU", # medium
33+
// "adapter-a", # lora_name
34+
// None, # extra_keys (may be omitted)
35+
// 0 # group_idx (may be omitted)
3336
// ]
3437
//
3538
// [
@@ -74,15 +77,18 @@ type KVEvent interface {
7477

7578
// BlockStoredEvent represents blocks being stored in KV cache
7679
// ------------------------------------------------------------
77-
// BlockStored (msgspec encoding order)
78-
// Python fields:
80+
// BlockStored (msgspec encoding order, vllm/distributed/kv_events.py)
81+
// Python fields, positions after the tag:
7982
//
80-
// [tag, block_hashes, parent_block_hash, token_ids, block_size, lora_id, medium]
83+
// [tag, block_hashes, parent_block_hash, token_ids, block_size,
84+
// lora_id, medium, lora_name, extra_keys, group_idx, ...]
8185
//
86+
// The struct is msgspec array_like with omit_defaults, so trailing fields that
87+
// equal their default (extra_keys, group_idx and newer spec fields) may be
88+
// omitted from the wire array. The decoder therefore reads positions 5+ by
89+
// index with bounds checks and tolerates shorter arrays from older vLLM builds.
8290
// ------------------------------------------------------------
8391
//
84-
// lora_id and medium are unused for now.
85-
//
8692
// Note: BlockHashes are converted at decode time:
8793
// - vLLM legacy format (int64) → stored as-is
8894
// - vLLM new format (32-byte SHA-256 from PR #23673) → first 8 bytes converted to int64
@@ -94,6 +100,15 @@ type BlockStoredEvent struct {
94100
ParentBlockHash *int64 // Decoded from vLLM, supports both old and new formats
95101
TokenIDs [][]byte
96102

103+
// Optional fields carried by newer vLLM builds (positions 5-9). Decoded
104+
// best-effort; nil when the connected vLLM does not emit them. These are
105+
// exposed here so prefix-cache routing can key and score on them; see
106+
// issue #2285. Consumption is handled separately.
107+
LoraID *int64 // position 5; deprecated upstream in favor of LoraName
108+
Medium *string // position 6; storage tier, e.g. "GPU"/"cpu" (nil = unspecified)
109+
LoraName *string // position 7; canonical adapter id
110+
GroupIdx *int64 // position 9; KV-cache group for hybrid-attention models
111+
97112
// NOTE: These are NOT part of msgpack
98113
Timestamp time.Time `msgpack:"-"`
99114
ModelName string `msgpack:"-"`

pkg/cache/kvcache/msgpack_decoder.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,39 @@ func parseEventArray(arr []interface{}) (KVEvent, error) {
145145
return nil, err
146146
}
147147

148-
return &BlockStoredEvent{
148+
ev := &BlockStoredEvent{
149149
Type: EventTypeBlockStored,
150150
BlockHashes: blockHashes,
151151
ParentBlockHash: parentHash,
152152
TokenIDs: tokens,
153-
}, nil
153+
}
154+
155+
// Optional fields added by newer vLLM builds. msgspec omit_defaults may
156+
// drop trailing ones, so read by position with bounds checks and leave
157+
// the rest nil. Position 8 (extra_keys) is skipped here; group_idx is
158+
// still indexed at its fixed position 9.
159+
if len(arr) > 5 {
160+
if ev.LoraID, err = toInt64Ptr(arr[5]); err != nil {
161+
return nil, fmt.Errorf("invalid lora_id: %w", err)
162+
}
163+
}
164+
if len(arr) > 6 {
165+
if ev.Medium, err = toStringPtr(arr[6]); err != nil {
166+
return nil, fmt.Errorf("invalid medium: %w", err)
167+
}
168+
}
169+
if len(arr) > 7 {
170+
if ev.LoraName, err = toStringPtr(arr[7]); err != nil {
171+
return nil, fmt.Errorf("invalid lora_name: %w", err)
172+
}
173+
}
174+
if len(arr) > 9 {
175+
if ev.GroupIdx, err = toInt64Ptr(arr[9]); err != nil {
176+
return nil, fmt.Errorf("invalid group_idx: %w", err)
177+
}
178+
}
179+
180+
return ev, nil
154181

155182
case EventTypeBlockRemoved:
156183
if len(arr) < 2 {
@@ -348,6 +375,24 @@ func toInt64Ptr(v any) (*int64, error) {
348375
return &val, nil
349376
}
350377

378+
// toStringPtr converts a nullable msgpack string field to *string.
379+
// A nil input (Python None) yields a nil pointer. msgpack may decode a string
380+
// as either string or []byte, so both are accepted.
381+
func toStringPtr(v any) (*string, error) {
382+
if v == nil {
383+
return nil, nil
384+
}
385+
switch s := v.(type) {
386+
case string:
387+
return &s, nil
388+
case []byte:
389+
str := string(s)
390+
return &str, nil
391+
default:
392+
return nil, fmt.Errorf("expected string, got %T", v)
393+
}
394+
}
395+
351396
func parseUint32(v any) (uint32, error) {
352397
switch x := v.(type) {
353398

pkg/cache/kvcache/msgpack_decoder_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,129 @@ func TestBlockRemovedEventWithBytesHashes(t *testing.T) {
372372
assert.Equal(t, "removed-model", removed.ModelName)
373373
assert.Equal(t, "removed-pod", removed.PodName)
374374
}
375+
376+
// --- Issue #2285: decoder must read lora_id/medium/lora_name/group_idx ---
377+
378+
// rawBlockStoredBatch marshals a single BlockStored event using the exact
379+
// positional layout vLLM emits (msgspec array_like, tag first), so these tests
380+
// assert against the real wire contract rather than Go-encoder symmetry.
381+
//
382+
// vLLM BlockStored positions (vllm/distributed/kv_events.py):
383+
//
384+
// [0]tag [1]block_hashes [2]parent_block_hash [3]token_ids [4]block_size
385+
// [5]lora_id [6]medium [7]lora_name [8]extra_keys [9]group_idx ...
386+
func rawBlockStoredBatch(t *testing.T, eventFields []interface{}) []byte {
387+
t.Helper()
388+
event := append([]interface{}{string(EventTypeBlockStored)}, eventFields...)
389+
batch := []interface{}{
390+
float64(1_700_000_000), // batch timestamp (seconds)
391+
[]interface{}{event},
392+
}
393+
data, err := msgpack.Marshal(batch)
394+
require.NoError(t, err)
395+
return data
396+
}
397+
398+
// blockStoredCore returns the four always-present fields after the tag:
399+
// block_hashes, parent_block_hash, token_ids, block_size.
400+
func blockStoredCore() []interface{} {
401+
return []interface{}{
402+
[]interface{}{int64(101), int64(102)}, // block_hashes
403+
nil, // parent_block_hash
404+
[]interface{}{int64(1), int64(2), int64(3), int64(4)}, // token_ids
405+
int64(4), // block_size
406+
}
407+
}
408+
409+
func decodeSingleBlockStored(t *testing.T, data []byte) *BlockStoredEvent {
410+
t.Helper()
411+
batch, err := DecodeEventBatch(data, "m", "p")
412+
require.NoError(t, err)
413+
require.Len(t, batch.Events, 1)
414+
ev, ok := batch.Events[0].(*BlockStoredEvent)
415+
require.True(t, ok, "expected *BlockStoredEvent")
416+
return ev
417+
}
418+
419+
func TestBlockStored_DecodesAllOptionalFields(t *testing.T) {
420+
fields := append(blockStoredCore(),
421+
int64(7), // lora_id
422+
"GPU", // medium
423+
"adapter-a", // lora_name
424+
nil, // extra_keys
425+
int64(3), // group_idx
426+
)
427+
ev := decodeSingleBlockStored(t, rawBlockStoredBatch(t, fields))
428+
429+
require.NotNil(t, ev.LoraID)
430+
assert.Equal(t, int64(7), *ev.LoraID)
431+
require.NotNil(t, ev.Medium)
432+
assert.Equal(t, "GPU", *ev.Medium)
433+
require.NotNil(t, ev.LoraName)
434+
assert.Equal(t, "adapter-a", *ev.LoraName)
435+
require.NotNil(t, ev.GroupIdx)
436+
assert.Equal(t, int64(3), *ev.GroupIdx)
437+
}
438+
439+
// group_idx sits at position 9, after extra_keys at 8. Verify it is read by
440+
// position even when extra_keys carries a non-nil payload.
441+
func TestBlockStored_GroupIdxReadPastExtraKeys(t *testing.T) {
442+
fields := append(blockStoredCore(),
443+
nil, // lora_id
444+
"cpu", // medium
445+
nil, // lora_name
446+
[]interface{}{[]interface{}{"k"}}, // extra_keys (non-nil)
447+
int64(5), // group_idx
448+
)
449+
ev := decodeSingleBlockStored(t, rawBlockStoredBatch(t, fields))
450+
451+
require.NotNil(t, ev.Medium)
452+
assert.Equal(t, "cpu", *ev.Medium)
453+
require.NotNil(t, ev.GroupIdx)
454+
assert.Equal(t, int64(5), *ev.GroupIdx)
455+
}
456+
457+
// None values (nil medium / lora_name) must decode to nil pointers, not "".
458+
func TestBlockStored_NilOptionalValues(t *testing.T) {
459+
fields := append(blockStoredCore(),
460+
nil, // lora_id
461+
nil, // medium
462+
nil, // lora_name
463+
nil, // extra_keys
464+
nil, // group_idx
465+
)
466+
ev := decodeSingleBlockStored(t, rawBlockStoredBatch(t, fields))
467+
468+
assert.Nil(t, ev.LoraID)
469+
assert.Nil(t, ev.Medium)
470+
assert.Nil(t, ev.LoraName)
471+
assert.Nil(t, ev.GroupIdx)
472+
}
473+
474+
// Older vLLM builds send only the first five positions. Decoding must still
475+
// succeed and leave the new fields nil (no panic, no error).
476+
func TestBlockStored_BackwardCompatShortArray(t *testing.T) {
477+
ev := decodeSingleBlockStored(t, rawBlockStoredBatch(t, blockStoredCore()))
478+
479+
assert.Equal(t, []int64{101, 102}, ev.BlockHashes)
480+
assert.Nil(t, ev.LoraID)
481+
assert.Nil(t, ev.Medium)
482+
assert.Nil(t, ev.LoraName)
483+
assert.Nil(t, ev.GroupIdx)
484+
}
485+
486+
// Intermediate length: through lora_name (position 7), no extra_keys/group_idx.
487+
func TestBlockStored_PartialOptionalFields(t *testing.T) {
488+
fields := append(blockStoredCore(),
489+
int64(2), // lora_id
490+
"GPU", // medium
491+
"adapter-b", // lora_name
492+
)
493+
ev := decodeSingleBlockStored(t, rawBlockStoredBatch(t, fields))
494+
495+
require.NotNil(t, ev.LoraID)
496+
assert.Equal(t, int64(2), *ev.LoraID)
497+
require.NotNil(t, ev.LoraName)
498+
assert.Equal(t, "adapter-b", *ev.LoraName)
499+
assert.Nil(t, ev.GroupIdx)
500+
}

0 commit comments

Comments
 (0)