Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 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
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"
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` does not list the pod that is doing the cleanup

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?
43 changes: 43 additions & 0 deletions managed/models/agent_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,49 @@ func FindPMMAgentsRunningOnNode(q *reform.Querier, nodeID string) ([]*Agent, err
return res, nil
}

// FindAgentsOnNode returns Agents attached to or running on the Node: node-level exporters, the
// pmm-agents themselves, and external exporters in pull mode.
func FindAgentsOnNode(q *reform.Querier, nodeID string) ([]*Agent, error) {
structs, err := q.SelectAllFrom(AgentTable, "WHERE runs_on_node_id = $1 OR node_id = $1 ORDER BY agent_id", nodeID)
if err != nil {
return nil, fmt.Errorf("failed to select Agents on Node %q: %w", nodeID, err)
}

res := make([]*Agent, len(structs))
for i, str := range structs {
decryptedAgent := DecryptAgent(*str.(*Agent)) //nolint:forcetypeassert
res[i] = &decryptedAgent
}

return res, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the same is achieved by

agents, err := FindAgents(ctx, AgentFilters{NodeID: <nodeID>})


// FindAgentsByPMMAgentIDs returns Agents started by any of the given pmm-agents.
func FindAgentsByPMMAgentIDs(q *reform.Querier, pmmAgentIDs []string) ([]*Agent, error) {
if len(pmmAgentIDs) == 0 {
return []*Agent{}, nil
}

p := strings.Join(q.Placeholders(1, len(pmmAgentIDs)), ", ")
tail := fmt.Sprintf("WHERE pmm_agent_id IN (%s) ORDER BY agent_id", p)
args := make([]any, len(pmmAgentIDs))
for i, id := range pmmAgentIDs {
args[i] = id
}
structs, err := q.SelectAllFrom(AgentTable, tail, args...)
if err != nil {
return nil, fmt.Errorf("failed to select Agents started by pmm-agents: %w", err)
}

res := make([]*Agent, len(structs))
for i, str := range structs {
decryptedAgent := DecryptAgent(*str.(*Agent)) //nolint:forcetypeassert

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DecryptAgent/EncryptAgent uses heavy algos - I would recommend checking for Querier's context for cancellation first before running these operations - maybe there is no results receiver anymore

res[i] = &decryptedAgent
}

return res, nil
}

Comment on lines +448 to +472

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very similar to the prev on:

agents, err := FindAgents(ctx, AgentFilters{PMMAgentID: <pmmAgentID>})

// FindPMMAgentsForService gets pmm-agents for service.
func FindPMMAgentsForService(q *reform.Querier, serviceID string) ([]*Agent, error) {
_, err := q.SelectOneFrom(ServiceTable, "WHERE service_id = $1", serviceID)
Expand Down
49 changes: 49 additions & 0 deletions managed/models/agent_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,55 @@ func TestAgentHelpers(t *testing.T) {
assert.Empty(t, agents)
})

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

agents, err := models.FindAgentsOnNode(q, "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

// find with non existing node.
agents, err = models.FindAgentsOnNode(q, "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.FindAgentsByPMMAgentIDs(q, []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.FindAgentsByPMMAgentIDs(q, []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)

agents, err = models.FindAgentsByPMMAgentIDs(q, nil)
require.NoError(t, err)
assert.Empty(t, agents)
})

t.Run("FindPMMAgentsForServicesOnNode", func(t *testing.T) {
q, teardown := setup(t)
defer teardown(t)
Expand Down
40 changes: 40 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I doubt about the place where it should be running. I think that HA Leader is a good candidate for this.


return db, nil
}

Expand Down Expand Up @@ -1517,6 +1519,44 @@ 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 this runs outside the migration transaction and only logs failures: tidying them up
// must never keep a replica from starting.
func removeStaleHANodes(ctx context.Context, db *reform.DB, params SetupDBParams) {
if params.HANodeID == "" || params.SetupFixtures == SkipFixtures {
return
}

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

nodes, err := StaleHANodes(db.WithContext(ctx), params.HANodeID, params.HAPeers)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does StaleHANodes mean? Add, Get, Remove, List, Update,....?

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).Warn("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