Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
0c3d3e9
PMM-15227 Remove scaled-down HA replicas from Inventory
4nte Aug 6, 2026
15aa2c5
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 6, 2026
b82c657
PMM-15227 Let the HA cleanup remove stale PMM Server Nodes
4nte Aug 6, 2026
0c04e87
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 6, 2026
1a1a3c5
PMM-15227 Log HA cleanup with structured fields
4nte Aug 6, 2026
d52d51a
PMM-15227 Correct the IsPMMServerNode comment
4nte Aug 6, 2026
c62e178
PMM-15227 Reject unbracketed IPv6 HA peers
4nte Aug 6, 2026
b2cbb72
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 6, 2026
eb6abb1
PMM-15227 Improve wording
4nte Aug 7, 2026
433b524
PMM-15227 Keep the pre-HA PMM Server Node
4nte Aug 10, 2026
ac9e496
PMM-15227 Pin PMM Server Node guards to a const
4nte Aug 10, 2026
dafc590
PMM-15227 Never let HA node cleanup block startup
4nte Aug 10, 2026
82eefa4
PMM-15227 Tighten the HA node cleanup
4nte Aug 10, 2026
a31b548
PMM-15227 Fail fast in the HA node test helper
4nte Aug 10, 2026
4b09b46
PMM-15227 Cover the Node ID ban under a lifted flag
4nte Aug 10, 2026
56a34df
PMM-15227 Widen the stale HA Node safety check
4nte Aug 11, 2026
dcaf91e
PMM-15227 Skip blank PMM_HA_PEERS entries
4nte Aug 11, 2026
18a6c9b
PMM-15227 Select only PMM Server nodes instead of all
4nte Aug 11, 2026
4168236
PMM-15227 Document when a stale Node is kept
4nte Aug 11, 2026
326f45e
PMM-15227 Fix typo
4nte Aug 11, 2026
b7b543e
PMM-15227 Improve docs for stale node removal
4nte Aug 11, 2026
25e9b85
PMM-15227 Add test for scale-to-one-replica sweep scenario
4nte Aug 11, 2026
79324be
PMM-15227 Bind the HA node cleanup to the startup ctx
4nte Aug 11, 2026
940d5d6
PMM-15227 Fix the advice for a Node we keep
4nte Aug 11, 2026
a8e4a51
PMM-15227 Close three gaps in the HA cleanup tests
4nte Aug 11, 2026
0a3f7b4
PMM-15227 Cover the HA peer parser directly
4nte Aug 11, 2026
799bfdb
Merge remote-tracking branch 'origin/main' into PMM-15227-remove-stal…
4nte Aug 11, 2026
34de666
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 11, 2026
c973aa5
PMM-15227 Drop the Active/Active comment claim
4nte Aug 12, 2026
c17e3fe
PMM-15227 Skip decryption on a cancelled context
4nte Aug 12, 2026
9010b36
PMM-15227 Rename, export and tighten the HA sweep
4nte Aug 12, 2026
1cd186a
PMM-15227 Run the HA node sweep on the leader
4nte Aug 12, 2026
05b84cc
PMM-15227 Fold the agent finders into AgentFilters
4nte Aug 12, 2026
f76164c
PMM-15227 Sweep stale HA nodes at startup, not on the leader
4nte Aug 13, 2026
7426fd9
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 13, 2026
5a9c5a6
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
ademidoff Aug 16, 2026
e343d33
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 17, 2026
cfd317c
PMM-15227 Document FindAgents filter asymmetries
4nte Aug 17, 2026
4b6bd20
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
travagliad Aug 17, 2026
c094a9c
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 25, 2026
fa12774
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 25, 2026
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
16 changes: 15 additions & 1 deletion documentation/docs/install-pmm/install-HA-clustered.md
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,20 @@ When you scale PMM HA up or down, **all PMM pods will be recreated**. This happe
- HAProxy continues routing to available pods during rollout
- No data loss (distributed storage)
- Rolling update strategy minimizes downtime
- The Nodes of removed replicas get removed from **Inventory > Nodes** once the remaining pods restart, unless one of the conditions in the note below applies

!!! info "When PMM keeps a stale Node"
The cleanup runs on each replica at startup, so its log messages appear on whichever pod restarted first. PMM logs a warning (`component=ha`) and keeps the Node when:

- the Node still monitors services, for example a remote instance that was added from that replica. Re-add those services from a running replica; the next restart removes the Node
- `PMM_HA_PEERS` carries no readable node names, for example bare IP addresses
- `PMM_HA_PEERS` names no peers at all, or does not list the pod doing the cleanup. Both contradict the peer list the chart generates, so they are reported as a failure to look for stale Nodes

To see what was skipped:

```sh
kubectl exec <pmm-pod> -n pmm -c pmm-ha -- grep -i "stale HA node" /srv/logs/pmm-managed.log
```

To scale PMM server replicas:

Expand Down Expand Up @@ -1206,4 +1220,4 @@ This Tech Preview release is designed to gather community feedback before GA. Yo
- What works well in your environment?
- What's challenging or confusing?
- What features are you missing?
- How does performance compare to single-instance deployments?
- How does performance compare to single-instance deployments?
25 changes: 25 additions & 0 deletions managed/models/agent_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,13 @@ func checkUniqueAgentID(q *reform.Querier, id string) error {
type AgentFilters struct {
// Return only Agents started by this pmm-agent.
PMMAgentID string
// Return only Agents started by any of these pmm-agents. An empty slice is not a filter.
Comment thread
4nte marked this conversation as resolved.
PMMAgentIDs []string
// Return only Agents that provide insights for that Node.
NodeID string
// Return only Agents attached to or running on that Node: node-level exporters, the pmm-agents
// themselves, and external exporters in pull mode.
OnNodeID string
// Return only Agents that provide insights for that Service.
ServiceID string
// Return Agents with provided type.
Expand All @@ -246,6 +251,14 @@ func FindAgents(q *reform.Querier, filters AgentFilters) ([]*Agent, error) {
args = append(args, filters.PMMAgentID)
idx++
}
if len(filters.PMMAgentIDs) != 0 {
p := strings.Join(q.Placeholders(idx, len(filters.PMMAgentIDs)), ", ")
conditions = append(conditions, "pmm_agent_id IN ("+p+")")
for _, id := range filters.PMMAgentIDs {
args = append(args, id)
}
idx += len(filters.PMMAgentIDs)
}
if filters.NodeID != "" {
_, err := FindNodeByID(q, filters.NodeID)
if err != nil {
Expand All @@ -255,6 +268,12 @@ func FindAgents(q *reform.Querier, filters AgentFilters) ([]*Agent, error) {
args = append(args, filters.NodeID)
idx++
}
if filters.OnNodeID != "" {
// No existence check: the callers tolerate a Node that another actor has just removed.
conditions = append(conditions, fmt.Sprintf("(runs_on_node_id = %s OR node_id = %s)", q.Placeholder(idx), q.Placeholder(idx+1)))
args = append(args, filters.OnNodeID, filters.OnNodeID)
idx += 2
}
if filters.ServiceID != "" {
_, err := FindServiceByID(q, filters.ServiceID)
if err != nil {
Expand Down Expand Up @@ -294,6 +313,12 @@ func FindAgents(q *reform.Querier, filters AgentFilters) ([]*Agent, error) {
return nil, err
}

// Decryption is not free; skip it when the caller has already gone away.
err = q.Context().Err()
if err != nil {
return nil, err
}

agents := make([]*Agent, len(structs))
for i, s := range structs {
decryptedAgent := DecryptAgent(*s.(*Agent)) //nolint:forcetypeassert
Expand Down
61 changes: 61 additions & 0 deletions managed/models/agent_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,67 @@ func TestAgentHelpers(t *testing.T) {
assert.Empty(t, agents)
})

t.Run("FindAgentsByOnNodeID", func(t *testing.T) {
q, teardown := setup(t)
defer teardown(t)

agents, err := models.FindAgents(q, models.AgentFilters{OnNodeID: "N1"})
require.NoError(t, err)
agentIDs := make([]string, len(agents))
for i, agent := range agents {
agentIDs[i] = agent.AgentID
assert.True(t,
pointer.GetString(agent.RunsOnNodeID) == "N1" || pointer.GetString(agent.NodeID) == "N1",
"%s is on neither runs_on_node_id nor node_id of N1", agent.AgentID)
}
assert.Contains(t, agentIDs, "A1") // a pmm-agent running on the Node
assert.Contains(t, agentIDs, "A3") // an exporter attached to the Node
assert.Contains(t, agentIDs, "A7") // attached to the Node, but started by a pmm-agent on N2
assert.NotContains(t, agentIDs, "A2") // bound to a Service, not to the Node

// NodeID alone can't answer this: it never matches the pmm-agent running on the Node.
agents, err = models.FindAgents(q, models.AgentFilters{NodeID: "N1"})
require.NoError(t, err)
for _, agent := range agents {
assert.NotEqual(t, "A1", agent.AgentID)
}

// Unlike NodeID, OnNodeID does not probe for the Node, so an unknown one is not an error.
agents, err = models.FindAgents(q, models.AgentFilters{OnNodeID: "X1"})
require.NoError(t, err)
assert.Empty(t, agents)
})

t.Run("FindAgentsByPMMAgentIDs", func(t *testing.T) {
q, teardown := setup(t)
defer teardown(t)

agents, err := models.FindAgents(q, models.AgentFilters{PMMAgentIDs: []string{"A1"}})
require.NoError(t, err)
agentIDs := make([]string, len(agents))
for i, agent := range agents {
agentIDs[i] = agent.AgentID
}
assert.Equal(t, []string{"A2", "A3"}, agentIDs)

agents, err = models.FindAgents(q, models.AgentFilters{PMMAgentIDs: []string{"A1", "A4"}})
require.NoError(t, err)
agentIDs = make([]string, len(agents))
for i, agent := range agents {
agentIDs[i] = agent.AgentID
}
assert.Equal(t, []string{"A2", "A3", "A5", "A6", "A7"}, agentIDs)

// An empty slice is not a filter, so it matches everything - callers that build the list
// dynamically have to check for that themselves.
filtered, err := models.FindAgents(q, models.AgentFilters{PMMAgentIDs: nil})
require.NoError(t, err)
all, err := models.FindAgents(q, models.AgentFilters{})
require.NoError(t, err)
assert.Equal(t, all, filtered)
assert.NotEmpty(t, filtered)
})

t.Run("FindPMMAgentsForServicesOnNode", func(t *testing.T) {
q, teardown := setup(t)
defer teardown(t)
Expand Down
4 changes: 2 additions & 2 deletions managed/models/agent_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ func GetRTAAgentTypes() []AgentType {
var v2_42 = version.MustParse("2.42.0-0")

// PMMServerAgentID is a special Agent ID representing pmm-agent on PMM Server.
// It takes the value of "pmm-server" in regular non-HA setups, while in Active/Active HA setups
// it is set to the actual pmm-agent's Agent ID, which is a UUID.
// It takes the value of "pmm-server" in non-HA setups, while in HA setups it is set to the actual
// pmm-agent's Agent ID, which is a UUID.
var PMMServerAgentID = string("pmm-server")

// AgentConfigFilePath is the default path to pmm-agent config file; it changes to /srv in HA setups.
Expand Down
54 changes: 54 additions & 0 deletions managed/models/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,8 @@ func SetupDB(ctx context.Context, sqlDB *sql.DB, params SetupDBParams) (*reform.
return nil, err
}

removeStaleHANodes(ctx, db, params.HANodeID, params.HAPeers)

return db, nil
}

Expand Down Expand Up @@ -1517,6 +1519,58 @@ func migrateDB(db *reform.DB, params SetupDBParams) error {
})
}

// removeStaleHANodes drops the Inventory Nodes of HA replicas that were scaled away. Those rows are
// cosmetic, so failures are only logged and never returned: tidying them up must not take a replica
// with it.
//
// It runs at startup on every replica, deliberately not on the elected leader. PMM_HA_PEERS is
// fixed into a process's environment when the pod starts and cannot be re-read, so only at startup
// is it guaranteed to describe the current cluster - the pod was just created from the current
// StatefulSet template. Leadership can be acquired much later: a rolling update recreates the
// leader, forcing an election that a not-yet-updated pod can win, and that pod would then sweep
// with a peer list older than the cluster. On a scale-up that deletes the new replica's Node and
// Agents, which was reproduced on a 2 -> 3 scale-up. It also has to work without a leader at all,
// since a replica scaled down to one cannot reach quorum.
//
// Replicas racing each other is harmless: each Node is removed in a transaction of its own, and one
// that another replica already took is tolerated.
//
// The localHANodeID argument is the calling replica's own PMM_HA_NODE_ID; see FindStaleHANodes.
func removeStaleHANodes(ctx context.Context, db *reform.DB, localHANodeID string, haPeers []string) {
if localHANodeID == "" {
return
}

l := logrus.WithFields(logrus.Fields{"component": "ha", "ha_node_id": localHANodeID})

nodes, err := FindStaleHANodes(db.WithContext(ctx), localHANodeID, haPeers)
if err != nil {
l.WithError(err).Warn("Failed to look for stale HA nodes.")
return
}

for _, node := range nodes {
nodeL := l.WithFields(logrus.Fields{"node_id": node.NodeID, "node_name": node.NodeName})

// A transaction per Node: a failure rolls that Node back whole instead of leaving it
// half-removed, and leaves the Nodes this sweep hasn't reached yet alone.
err := db.InTransactionContext(ctx, nil, func(tx *reform.TX) error {
return RemoveStaleHANode(tx.Querier, node.NodeID)
})
switch {
case err == nil:
nodeL.Info("Removed stale HA node, it is not a part of the cluster anymore.")
case errors.Is(err, reform.ErrNoRows), status.Code(err) == codes.NotFound:
nodeL.WithError(err).Info("Stale HA node was already removed by another replica.")
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
nodeL.WithError(err).Info("Startup was cancelled, stopping the removal of stale HA nodes.")
return
default:
nodeL.WithError(err).Warn("Failed to remove a stale HA node, keeping it.")
}
}
}

type agentConfig struct {
ID string `yaml:"id"`
}
Expand Down
33 changes: 33 additions & 0 deletions managed/models/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,36 @@ func TestLabels(t *testing.T) {
tests.AssertGRPCError(t, status.New(codes.InvalidArgument, `Invalid label name "__1".`), err)
})
}

// The two outcomes are not symmetric: an entry that yields no name stops the whole sweep, while one
// that yields a name is trusted as naming a live replica. Reading a name out of an entry that carries
// none would turn "keep every Node" into "remove every Node this entry didn't name".
func TestHAPeerNodeName(t *testing.T) {
for _, tc := range []struct {
peer string
name string
ok bool
}{
{peer: "pmm-ha-0.monitoring-service.pmm.svc.cluster.local", name: "pmm-ha-0", ok: true}, // what the chart renders
{peer: "pmm-ha-0.pmm-ha:9761", name: "pmm-ha-0", ok: true},
{peer: " pmm-ha-1.pmm-ha.pmm.svc.cluster.local ", name: "pmm-ha-1", ok: true}, // trimmed
{peer: "pmm-ha-2:9761", name: "pmm-ha-2", ok: true}, // a dotless host with a port
{peer: "pmm-ha-2", name: "pmm-ha-2", ok: true},
{peer: "10.244.1.7"}, // bare IPv4, with and without a port
{peer: "10.244.1.7:9761"},
{peer: "2001:db8::7"}, // IPv6, unbracketed and bracketed
{peer: "[2001:db8::7]:9761"},
{peer: "[2001:db8::7]"},
{peer: "pmm-ha-2/10.0.0.2"}, // memberlist's "name/address" form
{peer: "pmm-ha-2/[2001:db8::7]:9761"},
{peer: ":9761"},
{peer: ""},
{peer: " "},
} {
t.Run(tc.peer, func(t *testing.T) {
name, ok := haPeerNodeName(tc.peer)
assert.Equal(t, tc.ok, ok)
assert.Equal(t, tc.name, name)
})
}
}
Loading
Loading