Skip to content

Commit 0c9574c

Browse files
sjmiller609claude
andauthored
Cache hypervisor state and bound /vm.info calls in list path (#225)
Tracing on the previous list-path span PR showed single `instances.derive_state` spans of 10-37s on the dev fleet, with no children — all the time was inside `hv.GetVMInfo`. The cloud-hypervisor API socket serializes per VM, so a snapshot in flight from auto-standby parks every concurrent `/vm.info` call behind it, blocking the entire serial list loop. Two changes: 1. Cache the last observed hypervisor VM state per instance with a 5s TTL. List calls within the TTL skip the HTTP round-trip entirely. Lifecycle event notifications (start/stop/standby/restore/delete/...) write through the cache so it stays current with state changes hypeman itself drives. 2. Wrap the underlying `GetVMInfo` call in a 500ms timeout. On timeout the instance is reported as Unknown (matching today's behavior when the hypervisor is unreachable) and the next list call retries. Together these eliminate the 14-37s tails while leaving the hypervisor-of-last-resort check intact for crash detection via the TTL sweep. Co-authored-by: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 23578ce commit 0c9574c

3 files changed

Lines changed: 212 additions & 25 deletions

File tree

lib/instances/manager.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ type manager struct {
116116
resourceValidator ResourceValidator // Optional validator for aggregate resource limits
117117
instanceLocks sync.Map // map[string]*sync.RWMutex - per-instance locks
118118
bootMarkerScans sync.Map // map[string]time.Time next allowed boot-marker rescan
119+
hypervisorStateCache sync.Map // map[string]hypervisorStateCacheEntry - last observed hypervisor state per instance
119120
hostTopology *HostTopology // Cached host CPU topology
120121
metrics *Metrics
121122
meter metric.Meter
@@ -243,6 +244,7 @@ func (m *manager) notifyLifecycleEvent(ctx context.Context, action LifecycleEven
243244
if inst == nil {
244245
return
245246
}
247+
m.updateCachedHypervisorStateFromInstance(inst)
246248
m.lifecycleEvents.Notify(ctx, LifecycleEvent{
247249
Action: action,
248250
InstanceID: inst.Id,
@@ -251,6 +253,7 @@ func (m *manager) notifyLifecycleEvent(ctx context.Context, action LifecycleEven
251253
}
252254

253255
func (m *manager) notifyLifecycleDelete(ctx context.Context, instanceID string) {
256+
m.invalidateCachedHypervisorState(instanceID)
254257
m.lifecycleEvents.Notify(ctx, LifecycleEvent{
255258
Action: LifecycleEventDelete,
256259
InstanceID: instanceID,

lib/instances/query.go

Lines changed: 116 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,24 @@ const (
2525
programStartSentinelPrefix = "HYPEMAN-PROGRAM-START "
2626
agentReadySentinelPrefix = "HYPEMAN-AGENT-READY "
2727
bootMarkerRescanInterval = 1 * time.Second
28+
// hypervisorStateCacheTTL bounds how long a cached hypervisor state may be
29+
// reused before a fresh GetVMInfo call is required. Short enough to detect
30+
// guest-driven shutdowns promptly, long enough that bursty list calls
31+
// collapse onto one underlying socket query.
32+
hypervisorStateCacheTTL = 5 * time.Second
33+
// getVMInfoTimeout bounds a single hypervisor /vm.info query. The cloud
34+
// hypervisor API socket serializes requests, so a snapshot in flight can
35+
// otherwise park derive_state for tens of seconds.
36+
getVMInfoTimeout = 500 * time.Millisecond
2837
)
2938

39+
// hypervisorStateCacheEntry stores the last observed hypervisor VM state for
40+
// an instance, used to skip /vm.info queries on hot list paths.
41+
type hypervisorStateCacheEntry struct {
42+
state hypervisor.VMState
43+
refreshedAt time.Time
44+
}
45+
3046
// stateResult holds the result of state derivation
3147
type stateResult struct {
3248
State State
@@ -56,39 +72,55 @@ func (m *manager) deriveStateWithOptions(ctx context.Context, stored *StoredMeta
5672
// 1. Check if socket exists
5773
if _, err := os.Stat(stored.SocketPath); err != nil {
5874
// No socket - check for snapshot to distinguish Stopped vs Standby
75+
m.invalidateCachedHypervisorState(stored.Id)
5976
if m.hasSnapshot(stored.DataDir) {
6077
return stateResult{State: StateStandby}
6178
}
6279
return stateResult{State: StateStopped}
6380
}
6481

65-
// 2. Socket exists - query hypervisor for actual state
66-
hv, err := m.getHypervisor(stored.SocketPath, stored.HypervisorType)
67-
if err != nil {
68-
// Failed to create client - this is unexpected if socket exists
69-
errMsg := fmt.Sprintf("failed to create hypervisor client: %v", err)
70-
log.WarnContext(ctx, "failed to determine instance state",
71-
"instance_id", stored.Id,
72-
"socket", stored.SocketPath,
73-
"error", err,
74-
)
75-
return stateResult{State: StateUnknown, Error: &errMsg}
76-
}
82+
// 2. Socket exists - resolve hypervisor state, preferring the in-memory
83+
// cache (populated by lifecycle events and prior queries) and falling
84+
// back to a time-bounded /vm.info call.
85+
var hvState hypervisor.VMState
86+
if cached, ok := m.loadCachedHypervisorState(stored.Id); ok {
87+
span.SetAttributes(attribute.Bool("hypervisor_state.cache_hit", true))
88+
hvState = cached
89+
} else {
90+
span.SetAttributes(attribute.Bool("hypervisor_state.cache_hit", false))
91+
hv, err := m.getHypervisor(stored.SocketPath, stored.HypervisorType)
92+
if err != nil {
93+
// Failed to create client - this is unexpected if socket exists
94+
errMsg := fmt.Sprintf("failed to create hypervisor client: %v", err)
95+
log.WarnContext(ctx, "failed to determine instance state",
96+
"instance_id", stored.Id,
97+
"socket", stored.SocketPath,
98+
"error", err,
99+
)
100+
return stateResult{State: StateUnknown, Error: &errMsg}
101+
}
77102

78-
info, err := hv.GetVMInfo(ctx)
79-
if err != nil {
80-
// Socket exists but hypervisor is unreachable - this is unexpected
81-
errMsg := fmt.Sprintf("failed to query hypervisor: %v", err)
82-
log.WarnContext(ctx, "failed to query hypervisor state",
83-
"instance_id", stored.Id,
84-
"socket", stored.SocketPath,
85-
"error", err,
86-
)
87-
return stateResult{State: StateUnknown, Error: &errMsg}
103+
queryCtx, cancel := context.WithTimeout(ctx, getVMInfoTimeout)
104+
info, err := hv.GetVMInfo(queryCtx)
105+
cancel()
106+
if err != nil {
107+
// Socket exists but hypervisor query failed or timed out. The API
108+
// socket serializes requests, so a snapshot in flight will trip
109+
// the timeout — return Unknown and let the next list call retry.
110+
errMsg := fmt.Sprintf("failed to query hypervisor: %v", err)
111+
log.WarnContext(ctx, "failed to query hypervisor state",
112+
"instance_id", stored.Id,
113+
"socket", stored.SocketPath,
114+
"error", err,
115+
)
116+
return stateResult{State: StateUnknown, Error: &errMsg}
117+
}
118+
hvState = info.State
119+
m.storeCachedHypervisorState(stored.Id, hvState)
88120
}
89121

90122
// 3. Map hypervisor state to our state
91-
switch info.State {
123+
switch hvState {
92124
case hypervisor.StateCreated:
93125
return stateResult{State: StateCreated}
94126
case hypervisor.StateRunning:
@@ -106,15 +138,74 @@ func (m *manager) deriveStateWithOptions(ctx context.Context, stored *StoredMeta
106138
return stateResult{State: StateShutdown}
107139
default:
108140
// Unknown state - log and return Unknown
109-
errMsg := fmt.Sprintf("unexpected hypervisor state: %s", info.State)
141+
errMsg := fmt.Sprintf("unexpected hypervisor state: %s", hvState)
110142
log.WarnContext(ctx, "hypervisor returned unexpected state",
111143
"instance_id", stored.Id,
112-
"hypervisor_state", info.State,
144+
"hypervisor_state", hvState,
113145
)
114146
return stateResult{State: StateUnknown, Error: &errMsg}
115147
}
116148
}
117149

150+
// loadCachedHypervisorState returns the cached hypervisor state for an instance
151+
// when present and within the TTL window. Stale entries are treated as a miss.
152+
func (m *manager) loadCachedHypervisorState(id string) (hypervisor.VMState, bool) {
153+
v, ok := m.hypervisorStateCache.Load(id)
154+
if !ok {
155+
return "", false
156+
}
157+
entry, ok := v.(hypervisorStateCacheEntry)
158+
if !ok {
159+
return "", false
160+
}
161+
if m.nowUTC().Sub(entry.refreshedAt) > hypervisorStateCacheTTL {
162+
return "", false
163+
}
164+
return entry.state, true
165+
}
166+
167+
// storeCachedHypervisorState records the latest known hypervisor state for an
168+
// instance so subsequent derive_state calls within the TTL window avoid the
169+
// socket round-trip.
170+
func (m *manager) storeCachedHypervisorState(id string, state hypervisor.VMState) {
171+
m.hypervisorStateCache.Store(id, hypervisorStateCacheEntry{
172+
state: state,
173+
refreshedAt: m.nowUTC(),
174+
})
175+
}
176+
177+
// invalidateCachedHypervisorState drops the cached hypervisor state for an
178+
// instance. Called when the instance no longer has a live hypervisor socket
179+
// (Stopped, Standby, Deleted) or transitions through a state where the cached
180+
// value would be stale.
181+
func (m *manager) invalidateCachedHypervisorState(id string) {
182+
m.hypervisorStateCache.Delete(id)
183+
}
184+
185+
// updateCachedHypervisorStateFromInstance reconciles the hypervisor state
186+
// cache with the post-transition instance state observed by a lifecycle
187+
// event. Lifecycle events are the source of truth for state changes hypeman
188+
// itself drives; mirroring them into the cache avoids re-querying /vm.info
189+
// on the next list call.
190+
func (m *manager) updateCachedHypervisorStateFromInstance(inst *Instance) {
191+
if inst == nil {
192+
return
193+
}
194+
switch inst.State {
195+
case StateRunning, StateInitializing:
196+
m.storeCachedHypervisorState(inst.Id, hypervisor.StateRunning)
197+
case StateCreated:
198+
m.storeCachedHypervisorState(inst.Id, hypervisor.StateCreated)
199+
case StatePaused:
200+
m.storeCachedHypervisorState(inst.Id, hypervisor.StatePaused)
201+
case StateShutdown:
202+
m.storeCachedHypervisorState(inst.Id, hypervisor.StateShutdown)
203+
default:
204+
// Stopped, Standby, Unknown — no live socket worth caching.
205+
m.invalidateCachedHypervisorState(inst.Id)
206+
}
207+
}
208+
118209
func deriveRunningState(stored *StoredMetadata) State {
119210
if stored.ProgramStartedAt == nil {
120211
return StateInitializing

lib/instances/query_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77
"time"
88

9+
"github.com/kernel/hypeman/lib/hypervisor"
910
"github.com/kernel/hypeman/lib/paths"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
@@ -313,3 +314,95 @@ func TestAppLogPathsForMarkerScan_IgnoresArchivedLogs(t *testing.T) {
313314
paths := m.appLogPathsForMarkerScan(id)
314315
require.Equal(t, []string{logPath + ".2", logPath + ".1", logPath}, paths)
315316
}
317+
318+
func TestHypervisorStateCache_TTL(t *testing.T) {
319+
t.Parallel()
320+
321+
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
322+
m := &manager{}
323+
m.now = func() time.Time { return now }
324+
325+
_, ok := m.loadCachedHypervisorState("missing")
326+
require.False(t, ok)
327+
328+
m.storeCachedHypervisorState("vm-1", hypervisor.StateRunning)
329+
got, ok := m.loadCachedHypervisorState("vm-1")
330+
require.True(t, ok)
331+
require.Equal(t, hypervisor.StateRunning, got)
332+
333+
// Advance just under TTL — still a hit.
334+
now = now.Add(hypervisorStateCacheTTL - time.Millisecond)
335+
got, ok = m.loadCachedHypervisorState("vm-1")
336+
require.True(t, ok)
337+
require.Equal(t, hypervisor.StateRunning, got)
338+
339+
// Past TTL — treated as miss so derive_state will re-query.
340+
now = now.Add(2 * time.Millisecond)
341+
_, ok = m.loadCachedHypervisorState("vm-1")
342+
require.False(t, ok)
343+
}
344+
345+
func TestHypervisorStateCache_InvalidationAndUpdate(t *testing.T) {
346+
t.Parallel()
347+
348+
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
349+
m := &manager{}
350+
m.now = func() time.Time { return now }
351+
352+
m.storeCachedHypervisorState("vm-1", hypervisor.StateRunning)
353+
m.invalidateCachedHypervisorState("vm-1")
354+
_, ok := m.loadCachedHypervisorState("vm-1")
355+
require.False(t, ok)
356+
357+
// Lifecycle event with a live-socket state populates the cache.
358+
m.updateCachedHypervisorStateFromInstance(&Instance{
359+
StoredMetadata: StoredMetadata{Id: "vm-1"},
360+
State: StateInitializing,
361+
})
362+
got, ok := m.loadCachedHypervisorState("vm-1")
363+
require.True(t, ok)
364+
require.Equal(t, hypervisor.StateRunning, got)
365+
366+
m.updateCachedHypervisorStateFromInstance(&Instance{
367+
StoredMetadata: StoredMetadata{Id: "vm-1"},
368+
State: StatePaused,
369+
})
370+
got, ok = m.loadCachedHypervisorState("vm-1")
371+
require.True(t, ok)
372+
require.Equal(t, hypervisor.StatePaused, got)
373+
374+
// Standby has no live socket, so the cache must be cleared so the next
375+
// derive_state correctly short-circuits to Standby/Stopped via the
376+
// socket check rather than reusing a stale Running entry.
377+
m.updateCachedHypervisorStateFromInstance(&Instance{
378+
StoredMetadata: StoredMetadata{Id: "vm-1"},
379+
State: StateStandby,
380+
})
381+
_, ok = m.loadCachedHypervisorState("vm-1")
382+
require.False(t, ok)
383+
}
384+
385+
func TestNotifyLifecycleEvent_UpdatesAndDropsHypervisorStateCache(t *testing.T) {
386+
t.Parallel()
387+
388+
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
389+
m := &manager{
390+
lifecycleEvents: newLifecycleSubscribers(),
391+
}
392+
m.now = func() time.Time { return now }
393+
394+
inst := &Instance{
395+
StoredMetadata: StoredMetadata{Id: "vm-2"},
396+
State: StateRunning,
397+
}
398+
m.notifyLifecycleEvent(t.Context(), LifecycleEventStart, inst)
399+
got, ok := m.loadCachedHypervisorState("vm-2")
400+
require.True(t, ok)
401+
require.Equal(t, hypervisor.StateRunning, got)
402+
403+
// Delete must drop the cache so a future re-create with the same id does
404+
// not race with a stale entry from before deletion.
405+
m.notifyLifecycleDelete(t.Context(), "vm-2")
406+
_, ok = m.loadCachedHypervisorState("vm-2")
407+
require.False(t, ok)
408+
}

0 commit comments

Comments
 (0)