fix(parser): dedupe HPE AHS DIMM inventory per slot, newest snapshot wins

The AHS blackbox stores a DIMM inventory snapshot per POST cycle and
parseDIMMs flattens all of them. dedupeMemory keyed on serial, so a slot
whose module was swapped between captures survived once per historical
occupant — a 24-slot board reported 26 modules and "same P/N" vs
"mixed P/N" configs looked identical.

dedupeMemory now collapses to one entry per non-empty slot with the last
(most recent, since token order follows chronological blackbox-record
order) occurrence winning. Slotless entries keep the serial/part fallback.

See ADL-060.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-27 16:49:12 +03:00
co-authored by Claude Sonnet 5
parent 99d8f098ac
commit 632067fef5
2 changed files with 49 additions and 1 deletions
+20 -1
View File
@@ -1045,11 +1045,30 @@ func dedupeCPUs(items []models.CPU) []models.CPU {
return out return out
} }
// dedupeMemory collapses the DIMM inventory to one entry per physical slot.
//
// The AHS blackbox carries a separate inventory snapshot for every POST cycle,
// and parseDIMMs walks all of them. When a module is swapped between captures the
// same slot shows up with different serials; keying the dedupe on serial alone
// (as an earlier version did) let every historical occupant survive, inflating
// the module count. Tokens are appended in blackbox-record order, which is
// chronological, so the last occurrence for a slot is the most recent snapshot
// and wins. Entries without a slot label fall back to serial-based dedupe.
func dedupeMemory(items []models.MemoryDIMM) []models.MemoryDIMM { func dedupeMemory(items []models.MemoryDIMM) []models.MemoryDIMM {
slotIndex := make(map[string]int)
seen := make(map[string]bool) seen := make(map[string]bool)
out := make([]models.MemoryDIMM, 0, len(items)) out := make([]models.MemoryDIMM, 0, len(items))
for _, item := range items { for _, item := range items {
key := valueOr(item.SerialNumber, item.Slot+"|"+item.PartNumber) if item.Slot != "" {
if idx, ok := slotIndex[item.Slot]; ok {
out[idx] = item
continue
}
slotIndex[item.Slot] = len(out)
out = append(out, item)
continue
}
key := valueOr(item.SerialNumber, item.PartNumber)
if seen[key] { if seen[key] {
continue continue
} }
+29
View File
@@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser" "git.mchus.pro/mchus/logpile/internal/parser"
) )
@@ -153,6 +154,34 @@ func TestParseAHSInventory(t *testing.T) {
} }
} }
func TestDedupeMemorySlotSwap(t *testing.T) {
// The AHS blackbox keeps one inventory snapshot per POST cycle. When a module
// is swapped between captures the same slot appears with different serials, in
// chronological (token stream) order. dedupeMemory must keep one entry per
// slot, with the most recent snapshot winning.
items := []models.MemoryDIMM{
{Slot: "PROC 1 DIMM 1", PartNumber: "HMCG94AHBRA480N", SerialNumber: "AAA1"},
{Slot: "PROC 1 DIMM 7", PartNumber: "HMCG94AHBRA487N", SerialNumber: "OLD7"},
{Slot: "PROC 1 DIMM 1", PartNumber: "HMCG94AHBRA480N", SerialNumber: "AAA1"}, // repeated snapshot
{Slot: "PROC 1 DIMM 7", PartNumber: "HMCG94AHBRA480N", SerialNumber: "NEW7"}, // module swapped
}
out := dedupeMemory(items)
if len(out) != 2 {
t.Fatalf("expected 2 DIMMs (one per slot), got %d: %+v", len(out), out)
}
bySlot := map[string]models.MemoryDIMM{}
for _, d := range out {
bySlot[d.Slot] = d
}
if got := bySlot["PROC 1 DIMM 7"].SerialNumber; got != "NEW7" {
t.Fatalf("expected newest occupant NEW7 for slot 7, got %q", got)
}
if got := bySlot["PROC 1 DIMM 1"].SerialNumber; got != "AAA1" {
t.Fatalf("unexpected serial for slot 1: %q", got)
}
}
func TestParseAHSTruncatedEntry(t *testing.T) { func TestParseAHSTruncatedEntry(t *testing.T) {
p := &Parser{} p := &Parser{}
// Build archive where the last entry's declared size exceeds available data. // Build archive where the last entry's declared size exceeds available data.