|
| 1 | +// Copyright (C) 2023 Percona LLC |
| 2 | +// |
| 3 | +// This program is free software: you can redistribute it and/or modify |
| 4 | +// it under the terms of the GNU Affero General Public License as published by |
| 5 | +// the Free Software Foundation, either version 3 of the License, or |
| 6 | +// (at your option) any later version. |
| 7 | +// |
| 8 | +// This program is distributed in the hope that it will be useful, |
| 9 | +// but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 10 | +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 11 | +// GNU Affero General Public License for more details. |
| 12 | +// |
| 13 | +// You should have received a copy of the GNU Affero General Public License |
| 14 | +// along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 15 | + |
| 16 | +package models_test |
| 17 | + |
| 18 | +import ( |
| 19 | + "fmt" |
| 20 | + "testing" |
| 21 | + "time" |
| 22 | + |
| 23 | + "github.com/stretchr/testify/assert" |
| 24 | + "github.com/stretchr/testify/require" |
| 25 | + "gopkg.in/reform.v1" |
| 26 | + "gopkg.in/reform.v1/dialects/postgresql" |
| 27 | + |
| 28 | + "github.com/percona/pmm/managed/models" |
| 29 | + "github.com/percona/pmm/managed/utils/testdb" |
| 30 | +) |
| 31 | + |
| 32 | +func TestOmTopologyRuns(t *testing.T) { |
| 33 | + sqlDB := testdb.Open(t, models.SkipFixtures, nil) |
| 34 | + t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) |
| 35 | + |
| 36 | + db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) |
| 37 | + base := time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC) |
| 38 | + |
| 39 | + // insert records one run and its document, minutes apart so ordering is unambiguous. |
| 40 | + insert := func(t *testing.T, q *reform.Querier, id string, offset time.Duration, document string) { |
| 41 | + t.Helper() |
| 42 | + started := base.Add(offset) |
| 43 | + finished := started.Add(time.Second) |
| 44 | + observed := started.Add(-5 * time.Second) |
| 45 | + |
| 46 | + run := &models.OmTopologyRun{ |
| 47 | + RunID: id, StartedAt: started, FinishedAt: &finished, |
| 48 | + Status: models.OmTopologyRunSuccess, ServicesTotal: 14, ServicesResolved: 14, |
| 49 | + ProbesOK: 13, ServicesStale: 1, OriginNode: "pmm-server", |
| 50 | + Sources: models.OmTopologySourceReports{ |
| 51 | + {Source: "inventory", Status: "ok", Facts: 81}, |
| 52 | + {Source: "metrics", Status: "ok", Facts: 219, Detail: map[string]string{"queries": "13"}}, |
| 53 | + }, |
| 54 | + Errors: models.OmTopologyRunErrors{ |
| 55 | + {Scope: "query", Code: "vm_query_failed", Message: "boom"}, |
| 56 | + }, |
| 57 | + } |
| 58 | + snapshot := &models.OmTopologySnapshot{ |
| 59 | + GeneratedAt: finished, ObservedAt: &observed, |
| 60 | + SchemaVersion: 3, Document: []byte(document), |
| 61 | + } |
| 62 | + require.NoError(t, models.CreateOmTopologyRun(q, run, snapshot)) |
| 63 | + } |
| 64 | + |
| 65 | + t.Run("a run round-trips with its receipt and its document", func(t *testing.T) { |
| 66 | + q := db.Querier |
| 67 | + insert(t, q, "run-1", 0, `{"origin_node":"pmm-server"}`) |
| 68 | + |
| 69 | + run, err := models.FindOmTopologyRunByID(q, "run-1") |
| 70 | + require.NoError(t, err) |
| 71 | + assert.Equal(t, models.OmTopologyRunSuccess, run.Status) |
| 72 | + assert.Equal(t, int32(14), run.ServicesTotal) |
| 73 | + assert.Equal(t, int32(1), run.ServicesStale) |
| 74 | + assert.Equal(t, "pmm-server", run.OriginNode) |
| 75 | + |
| 76 | + // The per-source receipt is the part that makes a thin document legible. |
| 77 | + require.Len(t, run.Sources, 2) |
| 78 | + assert.Equal(t, "metrics", run.Sources[1].Source) |
| 79 | + assert.Equal(t, "13", run.Sources[1].Detail["queries"]) |
| 80 | + require.Len(t, run.Errors, 1) |
| 81 | + assert.Equal(t, "vm_query_failed", run.Errors[0].Code) |
| 82 | + |
| 83 | + snapshot, err := models.FindLatestOmTopologySnapshot(q) |
| 84 | + require.NoError(t, err) |
| 85 | + assert.Equal(t, "run-1", snapshot.RunID) |
| 86 | + assert.JSONEq(t, `{"origin_node":"pmm-server"}`, string(snapshot.Document)) |
| 87 | + assert.Equal(t, int32(3), snapshot.SchemaVersion) |
| 88 | + }) |
| 89 | + |
| 90 | + t.Run("runs come back newest first", func(t *testing.T) { |
| 91 | + q := db.Querier |
| 92 | + insert(t, q, "run-2", time.Minute, `{"n":2}`) |
| 93 | + insert(t, q, "run-3", 2*time.Minute, `{"n":3}`) |
| 94 | + |
| 95 | + runs, err := models.FindOmTopologyRuns(q, 2) |
| 96 | + require.NoError(t, err) |
| 97 | + require.Len(t, runs, 2) |
| 98 | + assert.Equal(t, "run-3", runs[0].RunID) |
| 99 | + assert.Equal(t, "run-2", runs[1].RunID) |
| 100 | + |
| 101 | + // And the newest document is the one a cold start restores. |
| 102 | + snapshot, err := models.FindLatestOmTopologySnapshot(q) |
| 103 | + require.NoError(t, err) |
| 104 | + assert.Equal(t, "run-3", snapshot.RunID) |
| 105 | + }) |
| 106 | + |
| 107 | + t.Run("pruning bounds the history and takes the documents with it", func(t *testing.T) { |
| 108 | + q := db.Querier |
| 109 | + for i := range 5 { |
| 110 | + insert(t, q, fmt.Sprintf("prune-%d", i), time.Duration(10+i)*time.Minute, `{}`) |
| 111 | + } |
| 112 | + |
| 113 | + require.NoError(t, models.PruneOmTopologyRuns(q, 3)) |
| 114 | + |
| 115 | + runs, err := models.FindOmTopologyRuns(q, 100) |
| 116 | + require.NoError(t, err) |
| 117 | + require.Len(t, runs, 3, "only the newest are kept") |
| 118 | + assert.Equal(t, "prune-4", runs[0].RunID) |
| 119 | + |
| 120 | + // The snapshot cascades, so retention needs no second sweep to stay bounded. |
| 121 | + _, err = models.FindOmTopologyRunByID(q, "prune-0") |
| 122 | + require.ErrorIs(t, err, models.ErrNotFound) |
| 123 | + |
| 124 | + var count int |
| 125 | + require.NoError(t, db.QueryRow("SELECT count(*) FROM om_topology_snapshots").Scan(&count)) |
| 126 | + assert.Equal(t, 3, count) |
| 127 | + }) |
| 128 | + |
| 129 | + t.Run("absent things report absence, not an empty value", func(t *testing.T) { |
| 130 | + q := db.Querier |
| 131 | + |
| 132 | + _, err := models.FindOmTopologyRunByID(q, "nope") |
| 133 | + require.ErrorIs(t, err, models.ErrNotFound) |
| 134 | + |
| 135 | + _, err = models.FindOmTopologyRunByID(q, "") |
| 136 | + require.Error(t, err, "an empty id is a caller mistake, not a miss") |
| 137 | + |
| 138 | + runs, err := models.FindOmTopologyRuns(q, 0) |
| 139 | + require.NoError(t, err) |
| 140 | + assert.Empty(t, runs) |
| 141 | + |
| 142 | + require.Error(t, models.PruneOmTopologyRuns(q, 0), "keeping nothing is never what a caller meant") |
| 143 | + }) |
| 144 | +} |
| 145 | + |
| 146 | +func TestOmTopologySnapshotAbsentOnEmptyDatabase(t *testing.T) { |
| 147 | + sqlDB := testdb.Open(t, models.SkipFixtures, nil) |
| 148 | + t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) |
| 149 | + |
| 150 | + db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) |
| 151 | + |
| 152 | + // The cold-start path depends on this being a clean miss rather than an error. |
| 153 | + _, err := models.FindLatestOmTopologySnapshot(db.Querier) |
| 154 | + require.ErrorIs(t, err, models.ErrNotFound) |
| 155 | +} |
0 commit comments