state: release persistMu before the NodeStore delete

Holding it serialised concurrent deletes into one NodeStore batch and
peer map build each, capping deletion at one batch tick per node.
This commit is contained in:
Kristoffer Dalby authored and Kristoffer Dalby committed 2026-10-23 18:23:53 +02:00
1 parent 371674de95
commit 5d21186f22
2 files changed
+58 -4

No files matched your search

+53
View File
@@ -628,6 +628,59 @@ func TestDeleteNodeReturnsRemovalOnPolicyFailure(t *testing.T) {
// write during pre-auth-key re-registration does not leave the NodeStore // write during pre-auth-key re-registration does not leave the NodeStore
// holding a node key that was never persisted: a restart would reload the old // holding a node key that was never persisted: a restart would reload the old
// row and the client's current key would no longer resolve, locking it out. // row and the client's current key would no longer resolve, locking it out.
// TestConcurrentDeleteNodesShareNodeStoreBatch deletes two nodes at once with
// a NodeStore that applies a batch only when it holds two writes. Holding
// persistMu across the NodeStore write gave every delete a batch, and so a
// peer map build, of its own; under churn that serialised deletion behind one
// rebuild each.
func TestConcurrentDeleteNodesShareNodeStoreBatch(t *testing.T) {
cfg := persistTestConfig(t.TempDir() + "/headscale.db")
cfg.Tuning.NodeStoreBatchSize = 2
cfg.Tuning.NodeStoreBatchTimeout = time.Hour
database, err := db.NewHeadscaleDatabase(cfg)
require.NoError(t, err)
user := database.CreateUserForTest("delete-batch-user")
nodes := database.CreateNodesForTest(user, 2, "delete-batch-node")
require.NoError(t, database.Close())
s, err := NewState(cfg)
require.NoError(t, err)
t.Cleanup(func() { _ = s.Close() })
var wg sync.WaitGroup
for _, n := range nodes {
node, ok := s.GetNodeByID(n.ID)
require.True(t, ok)
wg.Go(func() {
_, err := s.DeleteNode(node)
assert.NoError(t, err)
})
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("concurrent deletes must be able to share a NodeStore batch")
}
for _, n := range nodes {
_, ok := s.GetNodeByID(n.ID)
assert.False(t, ok, "deleted node must be gone from the NodeStore")
}
}
func TestPreAuthKeyReauthRevertsNodeStoreOnDBFailure(t *testing.T) { func TestPreAuthKeyReauthRevertsNodeStoreOnDBFailure(t *testing.T) {
dbPath := t.TempDir() + "/headscale.db" dbPath := t.TempDir() + "/headscale.db"
cfg := persistTestConfig(dbPath) cfg := persistTestConfig(dbPath)
+5 -4
View File
@@ -627,17 +627,18 @@ func (s *State) DeleteNode(node types.NodeView) ([]change.Change, error) {
s.persistMu.Lock() s.persistMu.Lock()
err := s.db.DeleteNode(node.AsStruct()) err := s.db.DeleteNode(node.AsStruct())
if err != nil { s.persistMu.Unlock()
s.persistMu.Unlock()
if err != nil {
return nil, err return nil, err
} }
// The database is the durable source of truth. Only remove the in-memory // The database is the durable source of truth. Only remove the in-memory
// node after its row is gone so a failed database write cannot make a live // node after its row is gone so a failed database write cannot make a live
// node look deleted until the next restart. // node look deleted until the next restart. persistMu stays out of this
// write: a persist that still finds the node updates no row, and holding
// it would give every concurrent delete a NodeStore batch of its own.
s.nodeStore.DeleteNode(node.ID()) s.nodeStore.DeleteNode(node.ID())
s.persistMu.Unlock()
s.ipAlloc.FreeIPs(node.IPs()) s.ipAlloc.FreeIPs(node.IPs())