Skip to content

Commit 1b0bca5

Browse files
committed
PMM-15326: Add the OM topology tables and models
Migration 119 creates om_topology_runs, one row per collection pass, and om_topology_snapshots, the topology document each pass produced. The document is JSONB rather than a relational tree because the topology model is still moving; schema_version is what a reader checks. A snapshot is deleted with its run, which is what bounds retention. Nothing reads these yet - the service that does comes in a later PR. Signed-off-by: Pawel Lebioda <pawel.lebioda@percona.com>
1 parent f7310ce commit 1b0bca5

5 files changed

Lines changed: 811 additions & 0 deletions

File tree

managed/models/database.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,40 @@ var databaseSchema = [][]string{
11851185
`ALTER TABLE dumps ADD COLUMN encrypted boolean NOT NULL DEFAULT false`,
11861186
`UPDATE dumps SET encrypted = false`,
11871187
},
1188+
119: {
1189+
// OM (OpenManager) topology collection: one row per pass, and the
1190+
// topology document it produced.
1191+
`CREATE TABLE om_topology_runs (
1192+
run_id VARCHAR PRIMARY KEY,
1193+
started_at TIMESTAMP NOT NULL,
1194+
finished_at TIMESTAMP,
1195+
status VARCHAR NOT NULL,
1196+
services_total INTEGER NOT NULL DEFAULT 0,
1197+
services_resolved INTEGER NOT NULL DEFAULT 0,
1198+
services_orphaned INTEGER NOT NULL DEFAULT 0,
1199+
probes_ok INTEGER NOT NULL DEFAULT 0,
1200+
services_stale INTEGER NOT NULL DEFAULT 0,
1201+
origin_node VARCHAR NOT NULL DEFAULT '',
1202+
sources JSONB,
1203+
errors JSONB,
1204+
created_at TIMESTAMP NOT NULL
1205+
)`,
1206+
`CREATE INDEX om_topology_runs_started_at_idx ON om_topology_runs (started_at DESC)`,
1207+
1208+
// The document is JSONB rather than a relational tree because the topology model
1209+
// is still moving; schema_version is what a reader checks. It is deleted with its
1210+
// run, which is what bounds retention.
1211+
`CREATE TABLE om_topology_snapshots (
1212+
run_id VARCHAR PRIMARY KEY REFERENCES om_topology_runs (run_id) ON DELETE CASCADE,
1213+
generated_at TIMESTAMP NOT NULL,
1214+
observed_at TIMESTAMP,
1215+
stale BOOLEAN NOT NULL DEFAULT false,
1216+
schema_version INTEGER NOT NULL,
1217+
document JSONB NOT NULL,
1218+
created_at TIMESTAMP NOT NULL
1219+
)`,
1220+
`CREATE INDEX om_topology_snapshots_generated_at_idx ON om_topology_snapshots (generated_at DESC)`,
1221+
},
11881222
}
11891223

11901224
// ^^^ Avoid default values in schema definition. ^^^

managed/models/om_helpers.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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
17+
18+
import (
19+
"errors"
20+
"fmt"
21+
22+
"gopkg.in/reform.v1"
23+
)
24+
25+
// CreateOmTopologyRun stores one run and the topology document it produced.
26+
//
27+
// Both in one call because they are one fact: a run with no document is not a state any
28+
// reader should have to handle, and the caller writes them inside a transaction so no
29+
// reader ever sees one without the other.
30+
func CreateOmTopologyRun(q *reform.Querier, run *OmTopologyRun, snapshot *OmTopologySnapshot) error {
31+
err := q.Insert(run)
32+
if err != nil {
33+
return fmt.Errorf("failed to insert OM run: %w", err)
34+
}
35+
if snapshot != nil {
36+
snapshot.RunID = run.RunID
37+
err = q.Insert(snapshot)
38+
if err != nil {
39+
return fmt.Errorf("failed to insert OM snapshot: %w", err)
40+
}
41+
}
42+
return nil
43+
}
44+
45+
// FindOmTopologyRuns returns the most recent runs, newest first.
46+
func FindOmTopologyRuns(q *reform.Querier, limit int) ([]*OmTopologyRun, error) {
47+
if limit <= 0 {
48+
return []*OmTopologyRun{}, nil
49+
}
50+
structs, err := q.SelectAllFrom(OmTopologyRunTable, "ORDER BY started_at DESC, run_id DESC LIMIT "+q.Placeholder(1), limit)
51+
if err != nil {
52+
return nil, fmt.Errorf("failed to select OM runs: %w", err)
53+
}
54+
runs := make([]*OmTopologyRun, len(structs))
55+
for i, s := range structs {
56+
runs[i] = s.(*OmTopologyRun) //nolint:forcetypeassert
57+
}
58+
return runs, nil
59+
}
60+
61+
// FindOmTopologyRunByID returns one run, or ErrNotFound when there is no such run.
62+
func FindOmTopologyRunByID(q *reform.Querier, runID string) (*OmTopologyRun, error) {
63+
if runID == "" {
64+
return nil, NewInvalidArgumentError("run_id shouldn't be empty")
65+
}
66+
run := &OmTopologyRun{RunID: runID}
67+
err := q.Reload(run)
68+
if err != nil {
69+
if errors.Is(err, reform.ErrNoRows) {
70+
return nil, ErrNotFound
71+
}
72+
return nil, fmt.Errorf("failed to select OM run: %w", err)
73+
}
74+
return run, nil
75+
}
76+
77+
// FindLatestOmTopologySnapshot returns the newest stored topology document, or ErrNotFound when
78+
// no collection has ever run.
79+
//
80+
// This is what lets a restarted pmm-managed serve the estate before it has collected
81+
// anything of its own.
82+
func FindLatestOmTopologySnapshot(q *reform.Querier) (*OmTopologySnapshot, error) {
83+
structs, err := q.SelectAllFrom(OmTopologySnapshotTable, "ORDER BY generated_at DESC LIMIT 1")
84+
if err != nil {
85+
return nil, fmt.Errorf("failed to select the latest OM snapshot: %w", err)
86+
}
87+
if len(structs) == 0 {
88+
return nil, ErrNotFound
89+
}
90+
return structs[0].(*OmTopologySnapshot), nil //nolint:forcetypeassert
91+
}
92+
93+
// PruneOmTopologyRuns deletes all but the newest keep runs, cascading to their snapshots.
94+
//
95+
// Retention has to be bounded here rather than by an operator: collection runs on a timer
96+
// and on every read past the cache, so the table grows on its own. Pruning on write keeps
97+
// it at a fixed size without a second scheduled job to forget about.
98+
func PruneOmTopologyRuns(q *reform.Querier, keep int) error {
99+
if keep <= 0 {
100+
return NewInvalidArgumentError("keep should be positive")
101+
}
102+
_, err := q.Exec(`
103+
DELETE FROM om_topology_runs
104+
WHERE run_id NOT IN (
105+
SELECT run_id FROM om_topology_runs ORDER BY started_at DESC, run_id DESC LIMIT `+q.Placeholder(1)+`
106+
)`, keep)
107+
if err != nil {
108+
return fmt.Errorf("failed to prune OM runs: %w", err)
109+
}
110+
return nil
111+
}

managed/models/om_helpers_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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

Comments
 (0)