Reset obsolete guest metadata scheduling on invalidation

The broad guest lifecycle proof caught a recovery regression when a known unavailable agent deliberately invalidated its cache. Clear that key’s obsolete refresh/backoff deadline without clearing active fetch ownership, independent installations or Proxmox uncertainty. Retain immediate available-poll recovery and early-failure backoff as distinct paths.

Contract-Neutral: Preserve existing recovery on deliberate guest metadata cache invalidation; no guest lock, uncertainty, command, public API, configuration or duration changes.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-07 19:47:50 +01:00
parent 9b77a4b6c6
commit ec4ee724de
3 changed files with 39 additions and 1 deletions
@@ -558,7 +558,10 @@ completed. A cold cache cannot override another owner's reservation or an early
failure's backoff. A denied reservation returns only previously retained
identity (or none), without creating fresh evidence, a transport deferral or a
new retry deadline. Other keys remain independent; completion releases only
in-flight ownership and preserves normal refresh/backoff scheduling.
in-flight ownership and preserves normal refresh/backoff scheduling. Deliberate
cache invalidation on agent unavailability also discards its obsolete scheduling
deadline, so a later available poll can begin a fresh metadata lifecycle; it
never discards an outstanding fetch owner or the shared Proxmox command fence.
`guest_metadata_reservation_test.go` exercises real HTTP enrichment with cold
and retained caches, active and expired scheduling holds, early unverified-lock
+6
View File
@@ -482,6 +482,12 @@ func (m *Monitor) clearGuestMetadataCache(instanceName, nodeName string, vmid in
delete(m.guestMetadataCache, key)
}
m.guestMetadataMu.Unlock()
// Deliberate invalidation when the agent is unavailable starts a new
// metadata lifecycle. Do not carry its obsolete refresh/backoff deadline
// into a later available poll, or discard an outstanding fetch's ownership.
m.guestMetadataLimiterMu.Lock()
delete(m.guestMetadataLimiter, key)
m.guestMetadataLimiterMu.Unlock()
}
func cloneStringSlice(src []string) []string {
@@ -203,3 +203,32 @@ func TestGuestMetadataColdCacheHonoursEarlyBackoff(t *testing.T) {
})
}
}
func TestGuestMetadataInvalidationKeepsActiveOwnership(t *testing.T) {
key := guestMetadataCacheKey("reservation", "node", 105)
other := guestMetadataCacheKey("independent", "node", 105)
future := time.Now().Add(time.Hour)
m := &Monitor{guestMetadataCache: map[string]guestMetadataCacheEntry{key: {osName: "old"}, other: {osName: "independent"}}, guestMetadataLimiter: map[string]time.Time{other: future}}
if !m.tryReserveGuestMetadataFetch(key, time.Now()) {
t.Fatal("owner could not reserve metadata")
}
m.clearGuestMetadataCache("reservation", "node", 105)
if _, exists := m.guestMetadataCache[key]; exists {
t.Error("invalidated metadata was retained")
}
if _, exists := m.guestMetadataLimiter[key]; exists {
t.Error("obsolete scheduling deadline survived invalidation")
}
if m.tryReserveGuestMetadataFetch(key, time.Now()) {
t.Fatal("invalidation admitted another in-flight metadata writer")
}
if m.guestMetadataCache[other].osName != "independent" || !m.guestMetadataLimiter[other].Equal(future) {
t.Error("invalidation crossed installation boundaries")
}
m.releaseGuestMetadataFetch(key)
assertMetadataReservationReleased(t, m)
if !m.tryReserveGuestMetadataFetch(key, time.Now()) {
t.Error("invalidated metadata did not allow a fresh lifecycle after completion")
}
m.releaseGuestMetadataFetch(key)
}