Repository: juanfont/headscale
Pull request #2509: Only read relevant nodes from database in PeerChangedResponse
Author: login: Enkelmann
Created: 2025-03-28T11:11:46Z | Merged: 2025-04-08T12:56:45Z

Querying the whole database becomes a performance issue if a lot of nodes exist in it, even if all those nodes are offline. In `PeerChangedResponse` we actually know which nodes need to be queried, so we can restrict the database queries to these nodes.

In my tests, `PeerChangedResponse` usually needed to query less than 5 nodes, so the change resulted in an order of magnitude better performance for a database with several hundred nodes (most of them offline).

- [x] have read the [CONTRIBUTING.md](./CONTRIBUTING.md) file
- [ ] raised a GitHub issue or discussed it on the projects chat beforehand
- [x] added unit tests
- [ ] added integration tests
- [ ] updated documentation if needed
- [x] updated CHANGELOG.md

I can also write a test if desired. The PR should not change any behavior, so I wanted to base tests on existing tests for `PeerChangedResponse`. However, I did not find any existing tests for this function, so I am happy for pointers where to start for testing `PeerChangedResponse`.



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5d5f36c1b..2a322dcf88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -87,6 +87,7 @@ The new policy can be used by setting the environment variable
   [#2493](https://github.com/juanfont/headscale/pull/2493)
   - If a OIDC provider doesn't include the `email_verified` claim in its ID
     tokens, Headscale will attempt to get it from the UserInfo endpoint.
+- Improve performance by only querying relevant nodes from the database for node updates [#2509](https://github.com/juanfont/headscale/pull/2509)
 
 ## 0.25.1 (2025-02-25)
 
diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go
index f36f66b74f..6aa7501883 100644
--- a/hscontrol/db/node.go
+++ b/hscontrol/db/node.go
@@ -35,21 +35,26 @@ var (
 	)
 )
 
-func (hsdb *HSDatabase) ListPeers(nodeID types.NodeID) (types.Nodes, error) {
+// ListPeers returns peers of node, regardless of any Policy or if the node is expired.
+// If no peer IDs are given, all peers are returned.
+// If at least one peer ID is given, only these peer nodes will be returned.
+func (hsdb *HSDatabase) ListPeers(nodeID types.NodeID, peerIDs ...types.NodeID) (types.Nodes, error) {
 	return Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) {
-		return ListPeers(rx, nodeID)
+		return ListPeers(rx, nodeID, peerIDs...)
 	})
 }
 
-// ListPeers returns all peers of node, regardless of any Policy or if the node is expired.
-func ListPeers(tx *gorm.DB, nodeID types.NodeID) (types.Nodes, error) {
+// ListPeers returns peers of node, regardless of any Policy or if the node is expired.
+// If no peer IDs are given, all peers are returned.
+// If at least one peer ID is given, only these peer nodes will be returned.
+func ListPeers(tx *gorm.DB, nodeID types.NodeID, peerIDs ...types.NodeID) (types.Nodes, error) {
 	nodes := types.Nodes{}
 	if err := tx.
 		Preload("AuthKey").
 		Preload("AuthKey.User").
 		Preload("User").
-		Where("id <> ?",
-			nodeID).Find(&nodes).Error; err != nil {
+		Where("id <> ?", nodeID).
+		Where(peerIDs).Find(&nodes).Error; err != nil {
 		return types.Nodes{}, err
 	}
 
@@ -58,19 +63,23 @@ func ListPeers(tx *gorm.DB, nodeID types.NodeID) (types.Nodes, error) {
 	return nodes, nil
 }
 
-func (hsdb *HSDatabase) ListNodes() (types.Nodes, error) {
+// ListNodes queries the database for either all nodes if no parameters are given
+// or for the given nodes if at least one node ID is given as parameter
+func (hsdb *HSDatabase) ListNodes(nodeIDs ...types.NodeID) (types.Nodes, error) {
 	return Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) {
-		return ListNodes(rx)
+		return ListNodes(rx, nodeIDs...)
 	})
 }
 
-func ListNodes(tx *gorm.DB) (types.Nodes, error) {
+// ListNodes queries the database for either all nodes if no parameters are given
+// or for the given nodes if at least one node ID is given as parameter
+func ListNodes(tx *gorm.DB, nodeIDs ...types.NodeID) (types.Nodes, error) {
 	nodes := types.Nodes{}
 	if err := tx.
 		Preload("AuthKey").
 		Preload("AuthKey.User").
 		Preload("User").
-		Find(&nodes).Error; err != nil {
+		Where(nodeIDs).Find(&nodes).Error; err != nil {
 		return nil, err
 	}
 
diff --git a/hscontrol/db/node_test.go b/hscontrol/db/node_test.go
index c92a44974c..22d85a84fe 100644
--- a/hscontrol/db/node_test.go
+++ b/hscontrol/db/node_test.go
@@ -752,3 +752,174 @@ func TestRenameNode(t *testing.T) {
 	})
 	assert.ErrorContains(t, err, "name is not unique")
 }
+
+func TestListPeers(t *testing.T) {
+	// Setup test database
+	db, err := newSQLiteTestDB()
+	if err != nil {
+		t.Fatalf("creating db: %s", err)
+	}
+
+	user, err := db.CreateUser(types.User{Name: "test"})
+	require.NoError(t, err)
+
+	user2, err := db.CreateUser(types.User{Name: "user2"})
+	require.NoError(t, err)
+
+	node1 := types.Node{
+		ID:             0,
+		MachineKey:     key.NewMachine().Public(),
+		NodeKey:        key.NewNode().Public(),
+		Hostname:       "test1",
+		UserID:         user.ID,
+		RegisterMethod: util.RegisterMethodAuthKey,
+		Hostinfo:       &tailcfg.Hostinfo{},
+	}
+
+	node2 := types.Node{
+		ID:             0,
+		MachineKey:     key.NewMachine().Public(),
+		NodeKey:        key.NewNode().Public(),
+		Hostname:       "test2",
+		UserID:         user2.ID,
+		RegisterMethod: util.RegisterMethodAuthKey,
+		Hostinfo:       &tailcfg.Hostinfo{},
+	}
+
+	err = db.DB.Save(&node1).Error
+	require.NoError(t, err)
+
+	err = db.DB.Save(&node2).Error
+	require.NoError(t, err)
+
+	err = db.DB.Transaction(func(tx *gorm.DB) error {
+		_, err := RegisterNode(tx, node1, nil, nil)
+		if err != nil {
+			return err
+		}
+		_, err = RegisterNode(tx, node2, nil, nil)
+		return err
+	})
+	require.NoError(t, err)
+
+	nodes, err := db.ListNodes()
+	require.NoError(t, err)
+
+	assert.Len(t, nodes, 2)
+
+	// No parameter means no filter, should return all peers
+	nodes, err = db.ListPeers(1)
+	require.NoError(t, err)
+	assert.Equal(t, len(nodes), 1)
+	assert.Equal(t, "test2", nodes[0].Hostname)
+
+	// Empty node list should return all peers
+	nodes, err = db.ListPeers(1, types.NodeIDs{}...)
+	require.NoError(t, err)
+	assert.Equal(t, len(nodes), 1)
+	assert.Equal(t, "test2", nodes[0].Hostname)
+
+	// No match in IDs should return empty list and no error
+	nodes, err = db.ListPeers(1, types.NodeIDs{3, 4, 5}...)
+	require.NoError(t, err)
+	assert.Equal(t, len(nodes), 0)
+
+	// Partial match in IDs
+	nodes, err = db.ListPeers(1, types.NodeIDs{2, 3}...)
+	require.NoError(t, err)
+	assert.Equal(t, len(nodes), 1)
+	assert.Equal(t, "test2", nodes[0].Hostname)
+
+	// Several matched IDs, but node ID is still filtered out
+	nodes, err = db.ListPeers(1, types.NodeIDs{1, 2, 3}...)
+	require.NoError(t, err)
+	assert.Equal(t, len(nodes), 1)
+	assert.Equal(t, "test2", nodes[0].Hostname)
+}
+
+func TestListNodes(t *testing.T) {
+	// Setup test database
+	db, err := newSQLiteTestDB()
+	if err != nil {
+		t.Fatalf("creating db: %s", err)
+	}
+
+	user, err := db.CreateUser(types.User{Name: "test"})
+	require.NoError(t, err)
+
+	user2, err := db.CreateUser(types.User{Name: "user2"})
+	require.NoError(t, err)
+
+	node1 := types.Node{
+		ID:             0,
+		MachineKey:     key.NewMachine().Public(),
+		NodeKey:        key.NewNode().Public(),
+		Hostname:       "test1",
+		UserID:         user.ID,
+		RegisterMethod: util.RegisterMethodAuthKey,
+		Hostinfo:       &tailcfg.Hostinfo{},
+	}
+
+	node2 := types.Node{
+		ID:             0,
+		MachineKey:     key.NewMachine().Public(),
+		NodeKey:        key.NewNode().Public(),
+		Hostname:       "test2",
+		UserID:         user2.ID,
+		RegisterMethod: util.RegisterMethodAuthKey,
+		Hostinfo:       &tailcfg.Hostinfo{},
+	}
+
+	err = db.DB.Save(&node1).Error
+	require.NoError(t, err)
+
+	err = db.DB.Save(&node2).Error
+	require.NoError(t, err)
+
+	err = db.DB.Transaction(func(tx *gorm.DB) error {
+		_, err := RegisterNode(tx, node1, nil, nil)
+		if err != nil {
+			return err
+		}
+		_, err = RegisterNode(tx, node2, nil, nil)
+		return err
+	})
+	require.NoError(t, err)
+
+	nodes, err := db.ListNodes()
+	require.NoError(t, err)
+
+	assert.Len(t, nodes, 2)
+
+	// No parameter means no filter, should return all nodes
+	nodes, err = db.ListNodes()
+	require.NoError(t, err)
+	assert.Equal(t, len(nodes), 2)
+	assert.Equal(t, "test1", nodes[0].Hostname)
+	assert.Equal(t, "test2", nodes[1].Hostname)
+
+	// Empty node list should return all nodes
+	nodes, err = db.ListNodes(types.NodeIDs{}...)
+	require.NoError(t, err)
+	assert.Equal(t, len(nodes), 2)
+	assert.Equal(t, "test1", nodes[0].Hostname)
+	assert.Equal(t, "test2", nodes[1].Hostname)
+
+	// No match in IDs should return empty list and no error
+	nodes, err = db.ListNodes(types.NodeIDs{3, 4, 5}...)
+	require.NoError(t, err)
+	assert.Equal(t, len(nodes), 0)
+
+	// Partial match in IDs
+	nodes, err = db.ListNodes(types.NodeIDs{2, 3}...)
+	require.NoError(t, err)
+	assert.Equal(t, len(nodes), 1)
+	assert.Equal(t, "test2", nodes[0].Hostname)
+
+	// Several matched IDs
+	nodes, err = db.ListNodes(types.NodeIDs{1, 2, 3}...)
+	require.NoError(t, err)
+	assert.Equal(t, len(nodes), 2)
+	assert.Equal(t, "test1", nodes[0].Hostname)
+	assert.Equal(t, "test2", nodes[1].Hostname)
+}
diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go
index 7a297bd3a1..b85bf3b0dc 100644
--- a/hscontrol/mapper/mapper.go
+++ b/hscontrol/mapper/mapper.go
@@ -255,27 +255,25 @@ func (m *Mapper) PeerChangedResponse(
 	patches []*tailcfg.PeerChange,
 	messages ...string,
 ) ([]byte, error) {
+	var err error
 	resp := m.baseMapResponse()
 
-	peers, err := m.ListPeers(node.ID)
-	if err != nil {
-		return nil, err
-	}
-
 	var removedIDs []tailcfg.NodeID
 	var changedIDs []types.NodeID
 	for nodeID, nodeChanged := range changed {
 		if nodeChanged {
-			changedIDs = append(changedIDs, nodeID)
+			if nodeID != node.ID {
+				changedIDs = append(changedIDs, nodeID)
+			}
 		} else {
 			removedIDs = append(removedIDs, nodeID.NodeID())
 		}
 	}
-
-	changedNodes := make(types.Nodes, 0, len(changedIDs))
-	for _, peer := range peers {
-		if slices.Contains(changedIDs, peer.ID) {
-			changedNodes = append(changedNodes, peer)
+	changedNodes := types.Nodes{}
+	if len(changedIDs) > 0 {
+		changedNodes, err = m.ListNodes(changedIDs...)
+		if err != nil {
+			return nil, err
 		}
 	}
 
@@ -482,8 +480,11 @@ func (m *Mapper) baseWithConfigMapResponse(
 	return &resp, nil
 }
 
-func (m *Mapper) ListPeers(nodeID types.NodeID) (types.Nodes, error) {
-	peers, err := m.db.ListPeers(nodeID)
+// ListPeers returns peers of node, regardless of any Policy or if the node is expired.
+// If no peer IDs are given, all peers are returned.
+// If at least one peer ID is given, only these peer nodes will be returned.
+func (m *Mapper) ListPeers(nodeID types.NodeID, peerIDs ...types.NodeID) (types.Nodes, error) {
+	peers, err := m.db.ListPeers(nodeID, peerIDs...)
 	if err != nil {
 		return nil, err
 	}
@@ -496,6 +497,22 @@ func (m *Mapper) ListPeers(nodeID types.NodeID) (types.Nodes, error) {
 	return peers, nil
 }
 
+// ListNodes queries the database for either all nodes if no parameters are given
+// or for the given nodes if at least one node ID is given as parameter
+func (m *Mapper) ListNodes(nodeIDs ...types.NodeID) (types.Nodes, error) {
+	nodes, err := m.db.ListNodes(nodeIDs...)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, node := range nodes {
+		online := m.notif.IsLikelyConnected(node.ID)
+		node.IsOnline = &online
+	}
+
+	return nodes, nil
+}
+
 func nodeMapToList(nodes map[uint64]*types.Node) types.Nodes {
 	ret := make(types.Nodes, 0)
 


Reviews:
user: login: kradalby
body: I think the general change makes sense, in general a change request is (in my observation) not more than a subset of nodes, like you call out. 

I am up for this, but it needs to be fetched as ones.
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T11:41:35Z
user: login: Enkelmann
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:20:52Z
user: login: Enkelmann
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:26:52Z
user: login: kradalby
body: Much better, couple of comments to simplify and keep around lest functions/code. 
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T14:06:26Z
user: login: Enkelmann
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T06:42:49Z
user: login: Enkelmann
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T06:45:46Z
user: login: kradalby
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T08:13:10Z
user: login: Enkelmann
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T06:11:50Z
user: login: kradalby
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T14:19:57Z
user: login: Enkelmann
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T06:11:27Z
user: login: kradalby
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T06:20:07Z
state: APPROVED
author_association: NONE
submitted_at: 2025-04-07T08:53:34Z
user: login: Enkelmann
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T09:04:41Z
state: APPROVED
author_association: NONE
submitted_at: 2025-04-08T09:30:56Z
user: login: kradalby
body: Great! thank you!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-08T12:56:29Z

Review comments:
diff_hunk: @@ -257,11 +259,6 @@ func (m *Mapper) PeerChangedResponse(
 ) ([]byte, error) {
 	resp := m.baseMapResponse()
 
-	peers, err := m.ListPeers(node.ID)
path: hscontrol/mapper/mapper.go
user: login: kradalby
body: I agree this is not necessary, but instead of listing all, we should just list the one from the change map.

It would make more sense to make `ListPeers(node NodeID, peerIDs ...NodeID)` and only fetch the ones that are needed here. 

created_at: 2025-03-28T11:38:49Z
author_association: COLLABORATOR
diff_hunk: @@ -273,13 +270,21 @@ func (m *Mapper) PeerChangedResponse(
 	}
 
 	changedNodes := make(types.Nodes, 0, len(changedIDs))
-	for _, peer := range peers {
-		if slices.Contains(changedIDs, peer.ID) {
-			changedNodes = append(changedNodes, peer)
+	for _, changedID := range changedIDs {
+		if changedID != node.ID {
+			changedNode, err := m.GetNode(changedID)
path: hscontrol/mapper/mapper.go
user: login: kradalby
body: The problem with doing it this way, and not as mentioned above is that we dont get transactional consistency. In this case, nodes might change in between calls to GetNode. While this might not be an obvious problem, it might lead to incredibly hard to debug problems.
created_at: 2025-03-28T11:40:06Z
author_association: COLLABORATOR
diff_hunk: @@ -273,13 +270,21 @@ func (m *Mapper) PeerChangedResponse(
 	}
 
 	changedNodes := make(types.Nodes, 0, len(changedIDs))
-	for _, peer := range peers {
-		if slices.Contains(changedIDs, peer.ID) {
-			changedNodes = append(changedNodes, peer)
+	for _, changedID := range changedIDs {
+		if changedID != node.ID {
+			changedNode, err := m.GetNode(changedID)
path: hscontrol/mapper/mapper.go
user: login: Enkelmann
body: I have reworked the code to use exactly one database query. I will also add a test to the database query function at least.
created_at: 2025-03-28T13:20:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -257,11 +259,6 @@ func (m *Mapper) PeerChangedResponse(
 ) ([]byte, error) {
 	resp := m.baseMapResponse()
 
-	peers, err := m.ListPeers(node.ID)
path: hscontrol/mapper/mapper.go
user: login: Enkelmann
body: I opted against changing `ListPeers` for now. It is also used for full map responses, where I think all peers are needed (although I have not looked too deeply into this function yet). But I can of course change the naming scheme if you like something like `ListPeersSubset` better than `ListNodesSubset`.
created_at: 2025-03-28T13:26:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -87,6 +87,7 @@ The new policy can be used by setting the environment variable
   [#2493](https://github.com/juanfont/headscale/pull/2493)
   - If a OIDC provider doesn't include the `email_verified` claim in its ID
     tokens, Headscale will attempt to get it from the UserInfo endpoint.
+- Improve performance by only querying relevant nodes from the database for node updates
path: CHANGELOG.md
user: login: kradalby
body: please link the pr
created_at: 2025-03-31T14:02:20Z
author_association: COLLABORATOR
diff_hunk: @@ -77,6 +77,28 @@ func ListNodes(tx *gorm.DB) (types.Nodes, error) {
 	return nodes, nil
 }
 
+func (hsdb *HSDatabase) ListNodesSubset(nodeIDs types.NodeIDs) (types.Nodes, error) {
path: hscontrol/db/node.go
user: login: kradalby
body: ```suggestion
func (hsdb *HSDatabase) ListNodes(nodeIDs ...types.NodeID) (types.Nodes, error) {
```

I would prefer to just have the same function for this, but use the list expansion argument like this so it remains optional for getting all.
created_at: 2025-03-31T14:03:58Z
author_association: COLLABORATOR
diff_hunk: @@ -257,11 +259,6 @@ func (m *Mapper) PeerChangedResponse(
 ) ([]byte, error) {
 	resp := m.baseMapResponse()
 
-	peers, err := m.ListPeers(node.ID)
path: hscontrol/mapper/mapper.go
user: login: kradalby
body: I think modifying both `ListNodes` and `ListPeers` to be able to take a optional list arg would be the best.
created_at: 2025-03-31T14:05:54Z
author_association: COLLABORATOR
diff_hunk: @@ -87,6 +87,7 @@ The new policy can be used by setting the environment variable
   [#2493](https://github.com/juanfont/headscale/pull/2493)
   - If a OIDC provider doesn't include the `email_verified` claim in its ID
     tokens, Headscale will attempt to get it from the UserInfo endpoint.
+- Improve performance by only querying relevant nodes from the database for node updates
path: CHANGELOG.md
user: login: Enkelmann
body: Done
created_at: 2025-04-01T06:42:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -77,6 +77,28 @@ func ListNodes(tx *gorm.DB) (types.Nodes, error) {
 	return nodes, nil
 }
 
+func (hsdb *HSDatabase) ListNodesSubset(nodeIDs types.NodeIDs) (types.Nodes, error) {
path: hscontrol/db/node.go
user: login: Enkelmann
body: Is implemented.
created_at: 2025-04-01T06:45:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -496,6 +491,20 @@ func (m *Mapper) ListPeers(nodeID types.NodeID) (types.Nodes, error) {
 	return peers, nil
 }
 
+func (m *Mapper) ListNodes(nodeIDs ...types.NodeIDs) (types.Nodes, error) {
path: hscontrol/mapper/mapper.go
user: login: kradalby
body: You need to pass it as types.NodeID (without the s), now you are passing it as a list of lists 
created_at: 2025-04-01T08:13:02Z
author_association: COLLABORATOR
diff_hunk: @@ -496,6 +491,20 @@ func (m *Mapper) ListPeers(nodeID types.NodeID) (types.Nodes, error) {
 	return peers, nil
 }
 
+func (m *Mapper) ListNodes(nodeIDs ...types.NodeIDs) (types.Nodes, error) {
path: hscontrol/mapper/mapper.go
user: login: Enkelmann
body: That is by design. When using an optional argument we face the problem of having two different types of empty lists, both of which are needed:
- The empty list of not using the optional argument. We want to return all nodes in this case.
- The empty list of using the optional argument, but with a list where all nodes have been filtered out. We want to return an empty list here and *not all nodes*.

One could use the `nil` and `[]type.NodeID{}` variants of empty lists to distinguish these two cases. But in my experience, code depending on different behavior between these two empty list variants is a future bug waiting to happen. I know that my design also has a hidden footgun in that one could pass more than one list of nodes as parameter to the function. But I would prefer having two `ListNodes` functions before changing the parameter type to `...types.NodeID`.

Side remark: I will wait until we have settled on a design for `ListNodes` before I take a second look at `ListPeers`.
created_at: 2025-04-02T06:11:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -496,6 +491,20 @@ func (m *Mapper) ListPeers(nodeID types.NodeID) (types.Nodes, error) {
 	return peers, nil
 }
 
+func (m *Mapper) ListNodes(nodeIDs ...types.NodeIDs) (types.Nodes, error) {
path: hscontrol/mapper/mapper.go
user: login: kradalby
body: > The empty list of using the optional argument, but with a list where all nodes have been filtered out. We want to return an empty list here and not all nodes.

This scenario does not make any sense to me, in the case where all nodes has been filtered out, you would have an empty list of IDs and you would instead not call for the database and return early.
created_at: 2025-04-02T14:19:57Z
author_association: COLLABORATOR
diff_hunk: @@ -496,6 +491,20 @@ func (m *Mapper) ListPeers(nodeID types.NodeID) (types.Nodes, error) {
 	return peers, nil
 }
 
+func (m *Mapper) ListNodes(nodeIDs ...types.NodeIDs) (types.Nodes, error) {
path: hscontrol/mapper/mapper.go
user: login: Enkelmann
body: Well, the proposed code does check it and returns early right before the database access would otherwise occur. In my opinion it is better than relying on the caller to know that he has to check it before calling `ListNodes`.

As context: In my load tests this case did actually happen and there was no check in `PeerChangedResponse` before querying the database for zero nodes.
created_at: 2025-04-03T06:11:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -496,6 +491,20 @@ func (m *Mapper) ListPeers(nodeID types.NodeID) (types.Nodes, error) {
 	return peers, nil
 }
 
+func (m *Mapper) ListNodes(nodeIDs ...types.NodeIDs) (types.Nodes, error) {
path: hscontrol/mapper/mapper.go
user: login: kradalby
body: I think it will be fine with a docstring explaining the filter. Current behaviour is not that ergonomic and I would want it to be.
created_at: 2025-04-03T06:20:07Z
author_association: COLLABORATOR
diff_hunk: @@ -496,6 +491,20 @@ func (m *Mapper) ListPeers(nodeID types.NodeID) (types.Nodes, error) {
 	return peers, nil
 }
 
+func (m *Mapper) ListNodes(nodeIDs ...types.NodeIDs) (types.Nodes, error) {
path: hscontrol/mapper/mapper.go
user: login: Enkelmann
body: Although I do not like it from a coding-style perspective, I implemented it now.
created_at: 2025-04-07T09:04:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: Enkelmann
created_at: 2025-03-28T13:49:43Z
body: The code is reworked to use exactly one database query and I also added a unit test. I have some concern that my unit test does not cover the case of a NodeID being zero, although the code to add nodes in the test seem to suggest that this case cannot happen anyway.
author_association: CONTRIBUTOR
user: login: ghost
created_at: 2025-04-07T08:53:06Z
body: <h3>Pull Request Revisions</h3>
<table><tr><th>Revision</th><th>Description</th></tr><tr><td><a href="https://review.ai/pr/juanfont/headscale/2509?rev=2">r2</a></td><td><details open><summary>Added peer filtering to ListPeers</summary>Expanded <code>ListPeers</code> method to support optional peer ID filtering in database and mapper layers</details></td></tr><tr><td><a href="https://review.ai/pr/juanfont/headscale/2509?rev=1">r1</a></td><td><details><summary>Optimize node database query performance</summary>Enhanced <code>ListNodes</code> to support filtering by node IDs, improving database query efficiency and reducing unnecessary data retrieval</details></td></tr></table>✅ AI review completed for <a href="https://review.ai/pr/juanfont/headscale/2509?rev=2">r2</a>
author_association: NONE
user: login: Enkelmann
created_at: 2025-04-07T09:03:34Z
body: Ok, `ListNodes` now returns all nodes when no parameter is given and the behavior is documented in a docstring. If you like this version, I can take a look at `ListPeers`.
author_association: CONTRIBUTOR
user: login: kradalby
created_at: 2025-04-07T09:24:24Z
body: Looks great, thank you!
author_association: COLLABORATOR
user: login: Enkelmann
created_at: 2025-04-08T09:32:51Z
body: I added the same functionality to ListPeers (currently not used anywhere).
As far as I can tell the integration test failures have nothing to do with the PR, so I have not done anything about them. 
author_association: CONTRIBUTOR