1 package sinks
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "testing"
8 "time"
9
10 "github.com/cybertec-postgresql/pgwatch/v6/internal/log"
11 "github.com/cybertec-postgresql/pgwatch/v6/internal/metrics"
12 "github.com/cybertec-postgresql/pgwatch/v6/internal/testutil"
13 "github.com/jackc/pgx/v5"
14 jsoniter "github.com/json-iterator/go"
15 "github.com/pashagolub/pgxmock/v5"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18 )
19
20 var ctx = log.WithLogger(context.Background(), log.NewNoopLogger())
21
22 func TestReadMetricSchemaType(t *testing.T) {
23 conn, err := pgxmock.NewPool()
24 assert.NoError(t, err)
25
26 pgw := PostgresWriter{
27 ctx: ctx,
28 sinkDb: conn,
29 }
30
31 conn.ExpectQuery("SELECT schema_type").
32 WillReturnError(errors.New("expected"))
33 assert.Error(t, pgw.ReadMetricSchemaType())
34
35 conn.ExpectQuery("SELECT schema_type").
36 WillReturnRows(pgxmock.NewRows([]string{"schema_type"}).AddRow(true))
37 assert.NoError(t, pgw.ReadMetricSchemaType())
38 assert.Equal(t, DbStorageSchemaTimescale, pgw.metricSchema)
39 }
40
41 func TestNewWriterFromPostgresConn(t *testing.T) {
42 a := assert.New(t)
43 opts := &CmdOpts{
44 BatchingDelay: time.Hour,
45 RetentionInterval: "1 day",
46 MaintenanceInterval: "1 day",
47 PartitionInterval: "1 hour",
48 }
49
50 t.Run("Success", func(*testing.T) {
51 conn, err := pgxmock.NewPool()
52 a.NoError(err)
53
54 conn.ExpectPing()
55 conn.ExpectQuery("SELECT extract").WithArgs("1 day", "1 day", "1 hour").WillReturnRows(
56 pgxmock.NewRows([]string{"col1", "col2", "col3"}).AddRow((24 * time.Hour).Seconds(), (24 * time.Hour).Seconds(), true),
57 )
58 conn.ExpectQuery("SELECT EXISTS").WithArgs("admin").WillReturnRows(pgxmock.NewRows([]string{"schema_type"}).AddRow(true))
59 conn.ExpectQuery("SELECT schema_type").WillReturnRows(pgxmock.NewRows([]string{"schema_type"}).AddRow(true))
60 for _, m := range metrics.GetDefaultBuiltInMetrics() {
61 conn.ExpectExec("SELECT admin.ensure_dummy_metrics_table").WithArgs(m).WillReturnResult(pgxmock.NewResult("EXECUTE", 1))
62 }
63
64 pgw, err := NewWriterFromPostgresConn(ctx, conn, opts)
65 a.NoError(err)
66 a.NotNil(pgw)
67 a.NoError(conn.ExpectationsWereMet())
68 })
69
70 t.Run("InitFail", func(*testing.T) {
71 conn, err := pgxmock.NewPool()
72 a.NoError(err)
73
74 conn.ExpectPing().WillReturnError(assert.AnError)
75
76 pgw, err := NewWriterFromPostgresConn(ctx, conn, opts)
77 a.Error(err)
78 a.Nil(pgw)
79 a.NoError(conn.ExpectationsWereMet())
80 })
81
82 t.Run("ReadMetricSchemaTypeFail", func(*testing.T) {
83 conn, err := pgxmock.NewPool()
84 a.NoError(err)
85
86 conn.ExpectPing()
87 conn.ExpectQuery("SELECT extract").WithArgs("1 day", "1 day", "1 hour").WillReturnRows(
88 pgxmock.NewRows([]string{"col1", "col2", "col3"}).AddRow((24 * time.Hour).Seconds(), (24 * time.Hour).Seconds(), true),
89 )
90 conn.ExpectQuery("SELECT EXISTS").WithArgs("admin").WillReturnRows(pgxmock.NewRows([]string{"schema_type"}).AddRow(true))
91 conn.ExpectQuery("SELECT schema_type").WillReturnError(assert.AnError)
92
93 pgw, err := NewWriterFromPostgresConn(ctx, conn, opts)
94 a.Error(err)
95 a.Nil(pgw)
96 a.NoError(conn.ExpectationsWereMet())
97 })
98
99 t.Run("EnsureBuiltinMetricDummiesFail", func(*testing.T) {
100 conn, err := pgxmock.NewPool()
101 a.NoError(err)
102
103 conn.ExpectPing()
104 conn.ExpectQuery("SELECT extract").WithArgs("1 day", "1 day", "1 hour").WillReturnRows(
105 pgxmock.NewRows([]string{"col1", "col2", "col3"}).AddRow((24 * time.Hour).Seconds(), (24 * time.Hour).Seconds(), true),
106 )
107 conn.ExpectQuery("SELECT EXISTS").WithArgs("admin").WillReturnRows(pgxmock.NewRows([]string{"schema_type"}).AddRow(true))
108 conn.ExpectQuery("SELECT schema_type").WillReturnRows(pgxmock.NewRows([]string{"schema_type"}).AddRow(true))
109 conn.ExpectExec("SELECT admin.ensure_dummy_metrics_table").WithArgs(pgxmock.AnyArg()).WillReturnError(assert.AnError)
110
111 pgw, err := NewWriterFromPostgresConn(ctx, conn, opts)
112 a.Error(err)
113 a.Nil(pgw)
114 a.NoError(conn.ExpectationsWereMet())
115 })
116 }
117
118 func TestSyncMetric(t *testing.T) {
119 conn, err := pgxmock.NewPool()
120 assert.NoError(t, err)
121 pgw := PostgresWriter{
122 ctx: ctx,
123 sinkDb: conn,
124 }
125 dbUnique := "mydb"
126 metricName := "mymetric"
127 op := AddOp
128 conn.ExpectExec("INSERT INTO admin\\.all_distinct_dbname_metrics").WithArgs(dbUnique, metricName).WillReturnResult(pgxmock.NewResult("EXECUTE", 1))
129 conn.ExpectExec("SELECT admin\\.ensure_dummy_metrics_table").WithArgs(metricName).WillReturnResult(pgxmock.NewResult("EXECUTE", 1))
130 err = pgw.SyncMetric(dbUnique, metricName, op)
131 assert.NoError(t, err)
132 assert.NoError(t, conn.ExpectationsWereMet())
133
134 op = InvalidOp
135 err = pgw.SyncMetric(dbUnique, metricName, op)
136 assert.NoError(t, err, "ignore unknown operation")
137 }
138
139 func TestWrite(t *testing.T) {
140 conn, err := pgxmock.NewPool()
141 assert.NoError(t, err)
142 ctx, cancel := context.WithCancel(ctx)
143 pgw := PostgresWriter{
144 ctx: ctx,
145 sinkDb: conn,
146 }
147 message := metrics.MeasurementEnvelope{
148 MetricName: "test_metric",
149 Data: metrics.Measurements{
150 {"number": 1, "string": "test_data"},
151 },
152 DBName: "test_db",
153 CustomTags: map[string]string{"foo": "boo"},
154 }
155
156 highLoadTimeout = 0
157 err = pgw.Write(message)
158 assert.NoError(t, err, "messages skipped due to high load")
159
160 highLoadTimeout = time.Second * 5
161 pgw.input = make(chan metrics.MeasurementEnvelope, cacheLimit)
162 err = pgw.Write(message)
163 assert.NoError(t, err, "write successful")
164
165 cancel()
166 err = pgw.Write(message)
167 assert.Error(t, err, "context canceled")
168 }
169
170 func TestCopyFromMeasurements_Basic(t *testing.T) {
171
172 data := []metrics.MeasurementEnvelope{
173 {
174 MetricName: "metric1",
175 DBName: "db1",
176 CustomTags: map[string]string{"env": "test"},
177 Data: metrics.Measurements{
178 {"epoch_ns": int64(1000), "value": 1},
179 {"epoch_ns": int64(2000), "value": 2},
180 {"epoch_ns": int64(3000), "value": 3},
181 },
182 },
183 }
184
185 cfm := newCopyFromMeasurements(data)
186
187
188 assert.Equal(t, "metric1", cfm.MetricName()[0], "Metric name should be obtained before Next()")
189 assert.True(t, cfm.Next(), "Should have first measurement")
190 values, err := cfm.Values()
191 assert.NoError(t, err)
192 assert.Len(t, values, 4)
193 assert.Equal(t, "db1", values[1])
194
195 assert.True(t, cfm.Next(), "Should have second measurement")
196 values, err = cfm.Values()
197 assert.NoError(t, err)
198 assert.Equal(t, "db1", values[1])
199
200 assert.True(t, cfm.Next(), "Should have third measurement")
201 values, err = cfm.Values()
202 assert.NoError(t, err)
203 assert.Equal(t, "db1", values[1])
204
205 assert.False(t, cfm.Next(), "Should not have more measurements")
206 assert.True(t, cfm.EOF(), "Should be at end")
207 }
208
209 func TestCopyFromMeasurements_MultipleEnvelopes(t *testing.T) {
210
211 data := []metrics.MeasurementEnvelope{
212 {
213 MetricName: "metric1",
214 DBName: "db1",
215 CustomTags: map[string]string{"env": "test1"},
216 Data: metrics.Measurements{
217 {"epoch_ns": int64(1000), "value": 1},
218 {"epoch_ns": int64(2000), "value": 2},
219 },
220 },
221 {
222 MetricName: "metric1",
223 DBName: "db2",
224 CustomTags: map[string]string{"env": "test2"},
225 Data: metrics.Measurements{
226 {"epoch_ns": int64(3000), "value": 3},
227 },
228 },
229 }
230
231 cfm := newCopyFromMeasurements(data)
232
233
234 assert.True(t, cfm.Next())
235 values, err := cfm.Values()
236 assert.NoError(t, err)
237 assert.Equal(t, "db1", values[1])
238
239 assert.True(t, cfm.Next())
240 values, err = cfm.Values()
241 assert.NoError(t, err)
242 assert.Equal(t, "db1", values[1])
243
244
245 assert.Equal(t, "metric1", cfm.MetricName()[0])
246 assert.True(t, cfm.Next())
247 values, err = cfm.Values()
248 assert.NoError(t, err)
249 assert.Equal(t, "db2", values[1])
250
251 assert.False(t, cfm.Next())
252 }
253
254 func TestCopyFromMeasurements_MetricBoundaries(t *testing.T) {
255
256 data := []metrics.MeasurementEnvelope{
257 {
258 MetricName: "metric1",
259 DBName: "db1",
260 CustomTags: map[string]string{},
261 Data: metrics.Measurements{
262 {"epoch_ns": int64(1000), "value": 1},
263 {"epoch_ns": int64(2000), "value": 2},
264 },
265 },
266 {
267 MetricName: "metric2",
268 DBName: "db1",
269 CustomTags: map[string]string{},
270 Data: metrics.Measurements{
271 {"epoch_ns": int64(3000), "value": 3},
272 },
273 },
274 {
275 MetricName: "metric2",
276 DBName: "db2",
277 CustomTags: map[string]string{},
278 Data: metrics.Measurements{
279 {"epoch_ns": int64(4000), "value": 4},
280 },
281 },
282 }
283
284 cfm := newCopyFromMeasurements(data)
285
286
287 assert.Equal(t, "metric1", cfm.MetricName()[0])
288 assert.True(t, cfm.Next())
289 assert.True(t, cfm.Next())
290
291
292 assert.False(t, cfm.Next())
293 assert.False(t, cfm.EOF(), "Should not be at EOF yet, there's more data")
294
295 assert.Equal(t, "metric2", cfm.MetricName()[0])
296 assert.True(t, cfm.Next())
297 assert.True(t, cfm.Next())
298
299 assert.False(t, cfm.Next())
300 assert.True(t, cfm.EOF(), "Should be at EOF after processing all measurements")
301 }
302
303 func TestCopyFromMeasurements_EmptyData(t *testing.T) {
304
305 cfm := newCopyFromMeasurements([]metrics.MeasurementEnvelope{})
306 assert.False(t, cfm.Next())
307 assert.True(t, cfm.EOF())
308 }
309
310 func TestCopyFromMeasurements_EmptyMeasurements(t *testing.T) {
311
312 data := []metrics.MeasurementEnvelope{
313 {
314 MetricName: "metric1",
315 DBName: "db1",
316 CustomTags: map[string]string{},
317 Data: metrics.Measurements{},
318 },
319 {
320 MetricName: "metric1",
321 DBName: "db2",
322 CustomTags: map[string]string{},
323 Data: metrics.Measurements{
324 {"epoch_ns": int64(1000), "value": 1},
325 },
326 },
327 }
328
329 cfm := newCopyFromMeasurements(data)
330
331
332 assert.True(t, cfm.Next())
333 values, err := cfm.Values()
334 assert.NoError(t, err)
335 assert.Equal(t, "db2", values[1])
336
337 assert.False(t, cfm.Next())
338 assert.True(t, cfm.EOF())
339 }
340
341 func TestCopyFromMeasurements_TagProcessing(t *testing.T) {
342
343 data := []metrics.MeasurementEnvelope{
344 {
345 MetricName: "metric1",
346 DBName: "db1",
347 CustomTags: map[string]string{"existing": "tag"},
348 Data: metrics.Measurements{
349 {
350 "epoch_ns": int64(1000),
351 "value": 1,
352 "tag_env": "production",
353 "tag_version": "1.0",
354 "normal_field": "stays",
355 },
356 },
357 },
358 {
359 MetricName: "metric1",
360 DBName: "db2",
361 CustomTags: nil,
362 Data: metrics.Measurements{
363 {
364 "epoch_ns": int64(1000),
365 "value": 1,
366 "tag_env": "production",
367 "tag_version": "1.0",
368 "normal_field": "stays",
369 },
370 },
371 },
372 }
373
374 cfm := newCopyFromMeasurements(data)
375 assert.True(t, cfm.Next())
376
377 values, err := cfm.Values()
378 assert.NoError(t, err)
379 assert.Len(t, values, 4)
380
381
382
383 dataJSON, ok := values[2].(string)
384 assert.True(t, ok, "Data should be JSON string")
385
386 var dataMap map[string]any
387 err = jsoniter.ConfigFastest.UnmarshalFromString(dataJSON, &dataMap)
388 assert.NoError(t, err)
389 assert.Contains(t, dataMap, "normal_field")
390 assert.NotContains(t, dataMap, "tag_env", "tag_env should not be in data")
391 assert.NotContains(t, dataMap, "tag_version", "tag_version should not be in data")
392
393
394 tagJSON, ok := values[3].(string)
395 assert.True(t, ok, "Tag data should be JSON string")
396
397 var tagMap map[string]string
398 err = jsoniter.ConfigFastest.UnmarshalFromString(tagJSON, &tagMap)
399 assert.NoError(t, err)
400 assert.Contains(t, tagMap, "existing")
401 assert.Contains(t, tagMap, "env", "tag_env should be converted to env")
402 assert.Contains(t, tagMap, "version", "tag_version should be converted to version")
403 assert.Equal(t, "production", tagMap["env"])
404 assert.Equal(t, "1.0", tagMap["version"])
405
406 assert.True(t, cfm.Next())
407 _, err = cfm.Values()
408 assert.NoError(t, err, "should process nil CustomTags without error")
409 }
410
411 func TestCopyFromMeasurements_JsonMarshaling(t *testing.T) {
412
413 data := []metrics.MeasurementEnvelope{
414 {
415 MetricName: "metric1",
416 DBName: "db1",
417 CustomTags: map[string]string{"env": "test"},
418 Data: metrics.Measurements{
419 {
420 "epoch_ns": int64(1000),
421 "value": 42,
422 "name": "test_measurement",
423 },
424 {
425 "epoch_ns": int64(1000),
426 "value": func() string {
427 return "should produce error while marshaled"
428 },
429 "name": "test_measurement",
430 },
431 },
432 },
433 }
434
435 cfm := newCopyFromMeasurements(data)
436 assert.True(t, cfm.Next())
437
438 values, err := cfm.Values()
439 assert.NoError(t, err)
440 assert.Len(t, values, 4)
441
442
443 assert.Equal(t, "db1", values[1])
444
445
446 dataJSON, ok := values[2].(string)
447 assert.True(t, ok, "Data should be JSON string")
448 assert.Contains(t, dataJSON, `"value":42`)
449 assert.Contains(t, dataJSON, `"name":"test_measurement"`)
450
451 tagJSON, ok := values[3].(string)
452 assert.True(t, ok, "Tag data should be JSON string")
453 assert.Contains(t, tagJSON, `"env":"test"`)
454
455 assert.True(t, cfm.Next())
456 _, err = cfm.Values()
457 assert.Error(t, err, "cannot marshal function value to JSON")
458
459 cfm.NextEnvelope()
460 assert.NotPanics(t, func() { _ = cfm.MetricName() })
461 }
462
463 func TestCopyFromMeasurements_ErrorHandling(t *testing.T) {
464
465 cfm := newCopyFromMeasurements([]metrics.MeasurementEnvelope{})
466 assert.NoError(t, cfm.Err(), "Err() should always return nil")
467 }
468
469 func TestCopyFromMeasurements_StateManagement(t *testing.T) {
470
471 data := []metrics.MeasurementEnvelope{
472 {
473 MetricName: "metric1",
474 DBName: "db1",
475 CustomTags: map[string]string{},
476 Data: metrics.Measurements{
477 {"epoch_ns": int64(1000), "value": 1},
478 },
479 },
480 {
481 MetricName: "metric2",
482 DBName: "db1",
483 CustomTags: map[string]string{},
484 Data: metrics.Measurements{
485 {"epoch_ns": int64(2000), "value": 2},
486 },
487 },
488 }
489
490 cfm := newCopyFromMeasurements(data)
491
492
493 assert.Equal(t, -1, cfm.envelopeIdx)
494 assert.Equal(t, -1, cfm.measurementIdx)
495 assert.Equal(t, "", cfm.metricName)
496
497
498 assert.True(t, cfm.Next())
499 assert.Equal(t, 0, cfm.envelopeIdx)
500 assert.Equal(t, 0, cfm.measurementIdx)
501 assert.Equal(t, "metric1", cfm.metricName)
502
503
504 assert.False(t, cfm.Next())
505
506 assert.Equal(t, "", cfm.metricName)
507 }
508
509 func TestCopyFromMeasurements_CopyFail(t *testing.T) {
510 a := assert.New(t)
511 r := require.New(t)
512
513 pgContainer, pgTearDown, err := testutil.SetupPostgresContainer()
514 r.NoError(err)
515 defer pgTearDown()
516
517 connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
518 r.NoError(err)
519 conn, err := pgx.Connect(ctx, connStr)
520 r.NoError(err)
521
522 _, err = conn.Exec(ctx, `CREATE TABLE IF NOT EXISTS test_metric (
523 time timestamptz not null default now(),
524 dbname text NOT NULL,
525 data jsonb,
526 tag_data jsonb)`)
527 r.NoError(err)
528
529 msgs := []metrics.MeasurementEnvelope{
530 {
531 MetricName: "test_metric",
532 Data: metrics.Measurements{
533 {"epoch_ns": int64(2000), "value": func() {}},
534 {"epoch_ns": int64(2000), "value": struct{}{}},
535 },
536 DBName: "test_db",
537 },
538 }
539
540 cfm := newCopyFromMeasurements(msgs)
541
542 for !cfm.EOF() {
543 _, err = conn.CopyFrom(context.Background(), cfm.MetricName(), targetColumns[:], cfm)
544 a.Error(err)
545 if err != nil {
546 if !cfm.NextEnvelope() {
547 break
548 }
549 }
550 }
551
552 }
553
554
555
556 func TestIntervalValidation(t *testing.T) {
557 a := assert.New(t)
558
559 pgContainer, pgTearDown, err := testutil.SetupPostgresContainer()
560 a.NoError(err)
561 defer pgTearDown()
562
563 connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
564 a.NoError(err)
565
566 opts := &CmdOpts{
567 PartitionInterval: "1 minute",
568 MaintenanceInterval: "-1 hours",
569 RetentionInterval: "00:01:30",
570 BatchingDelay: time.Second,
571 }
572
573 _, err = NewPostgresWriter(ctx, connStr, opts)
574 a.EqualError(err, "--partition-interval must be at least 1 hour, got: 1 minute")
575 opts.PartitionInterval = "1 hour"
576
577 _, err = NewPostgresWriter(ctx, connStr, opts)
578 a.EqualError(err, "--maintenance-interval must be a positive PostgreSQL interval or 0 to disable it")
579 opts.MaintenanceInterval = "0 hours"
580
581 _, err = NewPostgresWriter(ctx, connStr, opts)
582 a.Error(err)
583
584 invalidIntervals := []string{
585 "not an interval", "3 dayss",
586 "four hours",
587 }
588
589 for _, interval := range invalidIntervals {
590 opts.PartitionInterval = interval
591 _, err = NewPostgresWriter(ctx, connStr, opts)
592 a.Error(err)
593 opts.PartitionInterval = "1 hour"
594
595 opts.MaintenanceInterval = interval
596 _, err = NewPostgresWriter(ctx, connStr, opts)
597 a.Error(err)
598 opts.MaintenanceInterval = "1 hour"
599
600 opts.RetentionInterval = interval
601 _, err = NewPostgresWriter(ctx, connStr, opts)
602 a.Error(err)
603 opts.RetentionInterval = "1 hour"
604 }
605
606 validIntervals := []string{
607 "3 days 4 hours", "1 year",
608 "P3D", "PT3H", "0-02", "1 00:00:00",
609 "P0-02", "P1", "2 weeks",
610 }
611
612 for _, interval := range validIntervals {
613 opts.PartitionInterval = interval
614 opts.MaintenanceInterval = interval
615 opts.RetentionInterval = interval
616
617 _, err = NewPostgresWriter(ctx, connStr, opts)
618 a.NoError(err)
619 }
620 }
621
622 func TestPartitionInterval(t *testing.T) {
623 a := assert.New(t)
624 r := require.New(t)
625
626 pgContainer, pgTearDown, err := testutil.SetupPostgresContainer()
627 r.NoError(err)
628 defer pgTearDown()
629
630 connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
631 r.NoError(err)
632
633 opts := &CmdOpts{
634 PartitionInterval: "3 weeks",
635 RetentionInterval: "14 days",
636 MaintenanceInterval: "12 hours",
637 BatchingDelay: time.Second,
638 }
639
640 pgw, err := NewPostgresWriter(ctx, connStr, opts)
641 r.NoError(err)
642
643 conn, err := pgx.Connect(ctx, connStr)
644 r.NoError(err)
645
646 m := map[string]ExistingPartitionInfo{
647 "test_metric": {
648 time.Now(), time.Now().Add(time.Hour),
649 },
650 }
651 err = pgw.EnsureMetricTimePartsExist(m)
652 r.NoError(err)
653
654 var partitionsNum int
655 err = conn.QueryRow(ctx, "SELECT COUNT(*) FROM pg_partition_tree('test_metric');").Scan(&partitionsNum)
656 a.NoError(err)
657
658 a.Equal(5, partitionsNum)
659
660 part := pgw.partitionMapMetric["test_metric"]
661
662 a.Equal(part.StartTime.Add(3*7*24*time.Hour), part.EndTime)
663 }
664
665
666 func TestPartitionForwardTimeJump(t *testing.T) {
667 a := assert.New(t)
668 r := require.New(t)
669
670 pgContainer, pgTearDown, err := testutil.SetupPostgresContainer()
671 r.NoError(err)
672 defer pgTearDown()
673
674 connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
675 r.NoError(err)
676
677 opts := &CmdOpts{
678 PartitionInterval: "1 day",
679 RetentionInterval: "14 days",
680 MaintenanceInterval: "12 hours",
681 BatchingDelay: time.Second,
682 }
683
684 pgw, err := NewPostgresWriter(ctx, connStr, opts)
685 r.NoError(err)
686
687 conn, err := pgx.Connect(ctx, connStr)
688 r.NoError(err)
689 defer conn.Close(ctx)
690
691
692 early := time.Date(2026, 2, 5, 10, 0, 0, 0, time.UTC)
693 r.NoError(pgw.EnsureMetricTimePartsExist(map[string]ExistingPartitionInfo{
694 "jump_metric": {StartTime: early, EndTime: early},
695 }))
696
697
698
699 pgw.partitionMapMetric = make(map[string]ExistingPartitionInfo)
700
701
702
703 late := time.Date(2026, 8, 19, 0, 49, 18, 0, time.UTC)
704 err = pgw.EnsureMetricTimePartsExist(map[string]ExistingPartitionInfo{
705 "jump_metric": {StartTime: late, EndTime: late},
706 })
707
708 r.NoError(err)
709
710
711 part := pgw.partitionMapMetric["jump_metric"]
712 a.False(part.StartTime.IsZero(), "part_available_from must not be NULL")
713 a.False(part.EndTime.IsZero(), "part_available_to must not be NULL")
714 a.False(late.Before(part.StartTime), "new timestamp must be >= partition start")
715 a.True(late.Before(part.EndTime), "new timestamp must be < partition end")
716
717
718
719 var covering int
720 r.NoError(conn.QueryRow(ctx, `
721 SELECT count(*)
722 FROM pg_catalog.pg_class c
723 JOIN pg_catalog.pg_inherits i ON i.inhrelid = c.oid
724 JOIN pg_catalog.pg_class parent ON parent.oid = i.inhparent
725 WHERE c.relispartition
726 AND c.relnamespace = 'subpartitions'::regnamespace
727 AND parent.relname = 'jump_metric'
728 AND $1::timestamptz >= substring(pg_catalog.pg_get_expr(c.relpartbound, c.oid, true) from 'FOR VALUES FROM \(''([^'']+)''')::timestamptz
729 AND $1::timestamptz < substring(pg_catalog.pg_get_expr(c.relpartbound, c.oid, true) from 'TO \(''([^'']+)''')::timestamptz
730 `, late).Scan(&covering))
731 a.Equal(1, covering, "exactly one subpartition must cover the new timestamp")
732 }
733
734 func Test_Maintain(t *testing.T) {
735 a := assert.New(t)
736 r := require.New(t)
737
738 pgContainer, pgTearDown, err := testutil.SetupPostgresContainer()
739 r.NoError(err)
740 defer pgTearDown()
741
742 connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
743 r.NoError(err)
744 conn, err := pgx.Connect(ctx, connStr)
745 r.NoError(err)
746
747 opts := &CmdOpts{
748 PartitionInterval: "1 hour",
749 RetentionInterval: "1 hour",
750 MaintenanceInterval: "0 days",
751 BatchingDelay: time.Hour,
752 }
753
754 pgw, err := NewPostgresWriter(ctx, connStr, opts)
755 r.NoError(err)
756
757 t.Run("MaintainUniqueSources", func(_ *testing.T) {
758
759 err = pgw.SyncMetric("test", "test_metric_1", AddOp)
760 r.NoError(err)
761
762 var numOfEntries int
763 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics;").Scan(&numOfEntries)
764 a.NoError(err)
765 a.Equal(1, numOfEntries)
766
767
768 pgw.MaintainUniqueSources()
769
770
771 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics;").Scan(&numOfEntries)
772 a.NoError(err)
773 a.Equal(0, numOfEntries)
774
775 message := []metrics.MeasurementEnvelope{
776 {
777 MetricName: "test_metric_1",
778 Data: metrics.Measurements{
779 {"number": 1, "string": "test_data"},
780 },
781 DBName: "test_db",
782 },
783 }
784 pgw.flush(message)
785
786
787 pgw.MaintainUniqueSources()
788
789
790 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics;").Scan(&numOfEntries)
791 a.NoError(err)
792 a.Equal(1, numOfEntries)
793
794 _, err = conn.Exec(ctx, "DROP TABLE test_metric_1;")
795 r.NoError(err)
796
797
798 pgw.MaintainUniqueSources()
799 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics;").Scan(&numOfEntries)
800 a.NoError(err)
801 a.Equal(0, numOfEntries)
802 })
803
804 t.Run("MaintainUniqueSources_MultipleMetricsAndSources", func(_ *testing.T) {
805
806 err = pgw.EnsureMetricDummy("test_metric_a")
807 r.NoError(err)
808 err = pgw.EnsureMetricDummy("test_metric_b")
809 r.NoError(err)
810
811
812 _, err = conn.Exec(ctx, `
813 CREATE TABLE subpartitions.test_metric_a_2024w01 PARTITION OF public.test_metric_a FOR VALUES FROM ('2024-01-01') TO ('2024-01-08');
814 CREATE TABLE subpartitions.test_metric_b_2024w01 PARTITION OF public.test_metric_b FOR VALUES FROM ('2024-01-01') TO ('2024-01-08');
815 `)
816 r.NoError(err)
817
818
819 _, err = conn.Exec(ctx, `
820 INSERT INTO test_metric_a (time, dbname, data) VALUES
821 ('2024-01-03', 'db1', '{}'::jsonb),
822 ('2024-01-03', 'db2', '{}'::jsonb),
823 ('2024-01-03', 'db3', '{}'::jsonb)
824 `)
825 r.NoError(err)
826
827 _, err = conn.Exec(ctx, `
828 INSERT INTO test_metric_b (time, dbname, data) VALUES
829 ('2024-01-03', 'db1', '{}'::jsonb),
830 ('2024-01-03', 'db2', '{}'::jsonb)
831 `)
832 r.NoError(err)
833
834
835 pgw.MaintainUniqueSources()
836
837
838 var count int
839 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics WHERE metric = 'test_metric_a';").Scan(&count)
840 a.NoError(err)
841 a.Equal(3, count)
842
843 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics WHERE metric = 'test_metric_b';").Scan(&count)
844 a.NoError(err)
845 a.Equal(2, count)
846
847
848 _, err = conn.Exec(ctx, "DROP TABLE test_metric_a, test_metric_b;")
849 r.NoError(err)
850 pgw.MaintainUniqueSources()
851 })
852
853 t.Run("MaintainUniqueSources_StaleEntriesCleanup", func(_ *testing.T) {
854
855 err = pgw.EnsureMetricDummy("test_metric_c")
856 r.NoError(err)
857
858
859 _, err = conn.Exec(ctx, `
860 CREATE TABLE subpartitions.test_metric_c_2024w01 PARTITION OF public.test_metric_c FOR VALUES FROM ('2024-01-01') TO ('2024-01-08');
861 `)
862 r.NoError(err)
863
864
865 _, err = conn.Exec(ctx, `
866 INSERT INTO test_metric_c (time, dbname, data) VALUES
867 ('2024-01-03', 'db_active', '{}'::jsonb)
868 `)
869 r.NoError(err)
870
871
872 _, err = conn.Exec(ctx, "INSERT INTO admin.all_distinct_dbname_metrics (dbname, metric) VALUES ('db_active', 'test_metric_c'), ('db_stale1', 'test_metric_c'), ('db_stale2', 'test_metric_c');")
873 r.NoError(err)
874
875 var count int
876 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics WHERE metric = 'test_metric_c';").Scan(&count)
877 a.NoError(err)
878 a.Equal(3, count)
879
880
881 pgw.MaintainUniqueSources()
882
883 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics WHERE metric = 'test_metric_c';").Scan(&count)
884 a.NoError(err)
885 a.Equal(1, count)
886
887 var dbname string
888 err = conn.QueryRow(ctx, "SELECT dbname FROM admin.all_distinct_dbname_metrics WHERE metric = 'test_metric_c';").Scan(&dbname)
889 a.NoError(err)
890 a.Equal("db_active", dbname)
891
892
893 _, err = conn.Exec(ctx, "DROP TABLE test_metric_c;")
894 r.NoError(err)
895 pgw.MaintainUniqueSources()
896 })
897
898 t.Run("MaintainUniqueSources_AdvisoryLock", func(_ *testing.T) {
899
900 conn2, err := pgx.Connect(ctx, connStr)
901 r.NoError(err)
902 defer conn2.Close(ctx)
903
904
905 err = pgw.EnsureMetricDummy("test_metric_d")
906 r.NoError(err)
907
908 _, err = conn.Exec(ctx, `
909 CREATE TABLE subpartitions.test_metric_d_2024w01 PARTITION OF public.test_metric_d FOR VALUES FROM ('2024-01-01') TO ('2024-01-08');
910 `)
911 r.NoError(err)
912
913
914 _, err = conn.Exec(ctx, `
915 INSERT INTO test_metric_d (time, dbname, data) VALUES
916 ('2024-01-03', 'db1', '{}'::jsonb)
917 `)
918 r.NoError(err)
919
920
921 _, err = conn.Exec(ctx, "INSERT INTO admin.all_distinct_dbname_metrics (dbname, metric) VALUES ('db1', 'test_metric_d'), ('db_stale', 'test_metric_d');")
922 r.NoError(err)
923
924 var count int
925 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics WHERE metric = 'test_metric_d';").Scan(&count)
926 a.NoError(err)
927 a.Equal(2, count, "Should have 2 entries initially (1 active + 1 stale)")
928
929
930
931 var lockAcquired bool
932 err = conn2.QueryRow(ctx, "SELECT pg_try_advisory_lock(1571543679778230000);").Scan(&lockAcquired)
933 r.NoError(err)
934 a.True(lockAcquired, "Should acquire advisory lock")
935
936
937 pgw.MaintainUniqueSources()
938
939
940 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics WHERE metric = 'test_metric_d';").Scan(&count)
941 a.NoError(err)
942 a.Equal(2, count, "Stale entry should remain because maintenance was skipped due to lock")
943
944
945 _, err = conn2.Exec(ctx, "SELECT pg_advisory_unlock(1571543679778230000);")
946 r.NoError(err)
947
948
949 pgw.MaintainUniqueSources()
950
951
952 err = conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics WHERE metric = 'test_metric_d';").Scan(&count)
953 a.NoError(err)
954 a.Equal(1, count, "Only active entry should remain after maintenance runs")
955
956 var dbname string
957 err = conn.QueryRow(ctx, "SELECT dbname FROM admin.all_distinct_dbname_metrics WHERE metric = 'test_metric_d';").Scan(&dbname)
958 a.NoError(err)
959 a.Equal("db1", dbname, "Remaining entry should be the active one")
960
961
962 _, err = conn.Exec(ctx, "DROP TABLE test_metric_d;")
963 r.NoError(err)
964 pgw.MaintainUniqueSources()
965 })
966
967 t.Run("DeleteOldPartitions", func(_ *testing.T) {
968
969 err = pgw.SyncMetric("test", "test_metric_2", AddOp)
970 r.NoError(err)
971
972 boundStart := time.Now().Add(-1 * 2 * 24 * time.Hour).Format("2006-01-02")
973 boundEnd := time.Now().Add(-1 * 24 * time.Hour).Format("2006-01-02")
974
975
976 _, err = conn.Exec(ctx,
977 fmt.Sprintf(
978 `CREATE TABLE subpartitions.test_metric_2_yesterday
979 PARTITION OF public.test_metric_2
980 FOR VALUES FROM ('%s') TO ('%s')`,
981 boundStart, boundEnd),
982 )
983 a.NoError(err)
984 _, err = conn.Exec(ctx, "COMMENT ON TABLE subpartitions.test_metric_2_yesterday IS $$pgwatch-generated-metric-time-lvl$$")
985 a.NoError(err)
986
987 var partitionsNum int
988 err = conn.QueryRow(ctx, "SELECT COUNT(*) FROM pg_partition_tree('test_metric_2');").Scan(&partitionsNum)
989 a.NoError(err)
990 a.Equal(2, partitionsNum)
991
992 pgw.opts.RetentionInterval = "2 days"
993 pgw.DeleteOldPartitions()
994
995 err = conn.QueryRow(ctx, "SELECT COUNT(*) FROM pg_partition_tree('test_metric_2');").Scan(&partitionsNum)
996 a.NoError(err)
997 a.Equal(2, partitionsNum)
998
999 pgw.opts.RetentionInterval = "1 hour"
1000 pgw.DeleteOldPartitions()
1001
1002 err = conn.QueryRow(ctx, "SELECT COUNT(*) FROM pg_partition_tree('test_metric_2');").Scan(&partitionsNum)
1003 a.NoError(err)
1004 a.Equal(1, partitionsNum)
1005 })
1006
1007 t.Run("Epoch to Duration Conversion", func(_ *testing.T) {
1008 table := map[string]time.Duration{
1009 "1 hour": time.Hour,
1010 "2 hours": 2 * time.Hour,
1011 "4 days": 4 * 24 * time.Hour,
1012 "1 day": 24 * time.Hour,
1013 "1 year": 365.25 * 24 * time.Hour,
1014 "1 week": 7 * 24 * time.Hour,
1015 "3 weeks": 3 * 7 * 24 * time.Hour,
1016 "2 months": 2 * 30 * 24 * time.Hour,
1017 "1 month": 30 * 24 * time.Hour,
1018 }
1019
1020 for k, v := range table {
1021 opts := &CmdOpts{
1022 PartitionInterval: "1 hour",
1023 RetentionInterval: k,
1024 MaintenanceInterval: k,
1025 BatchingDelay: time.Hour,
1026 }
1027
1028 pgw, err := NewPostgresWriter(ctx, connStr, opts)
1029 a.NoError(err)
1030 a.Equal(pgw.retentionInterval, v)
1031 a.Equal(pgw.maintenanceInterval, v)
1032 }
1033 })
1034 }
1035
1036
1037
1038 func TestEnsureMetricTimePartsExist_SpecialMetricNames(t *testing.T) {
1039 r := require.New(t)
1040 a := assert.New(t)
1041
1042 pgContainer, pgTearDown, err := testutil.SetupPostgresContainer()
1043 r.NoError(err)
1044 defer pgTearDown()
1045
1046 connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
1047 r.NoError(err)
1048
1049 opts := &CmdOpts{
1050 PartitionInterval: "1 day",
1051 RetentionInterval: "7 days",
1052 MaintenanceInterval: "12 hours",
1053 BatchingDelay: time.Second,
1054 }
1055 pgw, err := NewPostgresWriter(ctx, connStr, opts)
1056 r.NoError(err)
1057
1058 specialNames := []string{
1059 "metric.new",
1060 "Metric.With.Dots",
1061 "UPPERCASE_METRIC",
1062 "MixedCase_Metric-123",
1063 "metric-with-hyphens",
1064 "metric_with_underscores",
1065 "Metric123.Test_Name-456",
1066 }
1067
1068 m := make(map[string]ExistingPartitionInfo)
1069 for _, name := range specialNames {
1070 m[name] = ExistingPartitionInfo{
1071 StartTime: time.Now(),
1072 EndTime: time.Now().Add(time.Hour),
1073 }
1074 }
1075
1076 err = pgw.EnsureMetricTimePartsExist(m)
1077 r.NoError(err, "EnsureMetricTimePartsExist should handle special metric names")
1078
1079 conn, err := pgx.Connect(ctx, connStr)
1080 r.NoError(err)
1081 defer conn.Close(ctx)
1082
1083 var partitionCount int
1084 err = conn.QueryRow(ctx, `SELECT COUNT(*) FROM pg_partition_tree('"metric.new"') WHERE level = 1`).Scan(&partitionCount)
1085 r.NoError(err)
1086
1087 a.Equal(4, partitionCount)
1088 }
1089
1090
1091
1092
1093 func TestEnsureMetricTimePartsExist_IdempotentAcrossRestarts(t *testing.T) {
1094 r := require.New(t)
1095 a := assert.New(t)
1096
1097 pgContainer, pgTearDown, err := testutil.SetupPostgresContainer()
1098 r.NoError(err)
1099 defer pgTearDown()
1100
1101 connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
1102 r.NoError(err)
1103
1104 opts := &CmdOpts{
1105 PartitionInterval: "1 day",
1106 RetentionInterval: "7 days",
1107 MaintenanceInterval: "12 hours",
1108 BatchingDelay: time.Second,
1109 }
1110
1111 m := map[string]ExistingPartitionInfo{
1112 "restart_test_metric": {
1113 StartTime: time.Now(),
1114 EndTime: time.Now().Add(time.Hour),
1115 },
1116 }
1117
1118 for i := range 5 {
1119 pgw, err := NewPostgresWriter(ctx, connStr, opts)
1120 r.NoError(err)
1121 r.NoError(pgw.EnsureMetricTimePartsExist(m))
1122
1123 conn, err := pgx.Connect(ctx, connStr)
1124 r.NoError(err)
1125 var count int
1126 err = conn.QueryRow(ctx, "SELECT COUNT(*) FROM pg_partition_tree('restart_test_metric') WHERE isleaf").Scan(&count)
1127 conn.Close(ctx)
1128 r.NoError(err)
1129 a.Equal(4, count, "partition count should not grow on restart %d", i+1)
1130 }
1131 }
1132
1133 func TestFlush_SinkDBIsDown(t *testing.T) {
1134 r := require.New(t)
1135
1136 pgContainer, pgTearDown, err := testutil.SetupPostgresContainer()
1137 r.NoError(err)
1138
1139 connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
1140 r.NoError(err)
1141
1142 opts := &CmdOpts{
1143 PartitionInterval: "1 day",
1144 RetentionInterval: "7 days",
1145 MaintenanceInterval: "12 hours",
1146 BatchingDelay: time.Second,
1147 }
1148
1149 pgw, err := NewPostgresWriter(ctx, connStr, opts)
1150 r.NoError(err)
1151
1152 pgTearDown()
1153
1154 msgs := []metrics.MeasurementEnvelope{
1155 {
1156 MetricName: "test_metric",
1157 Data: metrics.Measurements{
1158 {"epoch_ns": time.Now().UnixNano(), "value": 1},
1159 },
1160 DBName: "test_db",
1161 },
1162 }
1163
1164
1165 pgw.flush(msgs)
1166 }
1167
1168
1169
1170
1171
1172 func TestDropAllMetricTables(t *testing.T) {
1173 a := assert.New(t)
1174 r := require.New(t)
1175
1176 pgContainer, pgTearDown, err := testutil.SetupPostgresContainer()
1177 r.NoError(err)
1178 defer pgTearDown()
1179
1180 connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
1181 r.NoError(err)
1182 conn, err := pgx.Connect(ctx, connStr)
1183 r.NoError(err)
1184 defer conn.Close(ctx)
1185
1186 opts := &CmdOpts{
1187 PartitionInterval: "1 hour",
1188 RetentionInterval: "1 hour",
1189 MaintenanceInterval: "0 days",
1190 BatchingDelay: time.Hour,
1191 }
1192
1193 pgw, err := NewPostgresWriter(ctx, connStr, opts)
1194 r.NoError(err)
1195
1196
1197 r.NoError(pgw.SyncMetric("test", "test_metric_a", AddOp))
1198 r.NoError(pgw.SyncMetric("test", "test_metric_b", AddOp))
1199
1200
1201
1202 _, err = conn.Exec(ctx, `
1203 CREATE TABLE subpartitions.test_metric_a_2024w01 PARTITION OF public.test_metric_a FOR VALUES FROM ('2024-01-01') TO ('2024-01-08');
1204 COMMENT ON TABLE subpartitions.test_metric_a_2024w01 IS 'pgwatch-generated-metric-time-lvl';
1205 CREATE TABLE subpartitions.test_metric_b_2024w01 PARTITION OF public.test_metric_b FOR VALUES FROM ('2024-01-01') TO ('2024-01-08');
1206 COMMENT ON TABLE subpartitions.test_metric_b_2024w01 IS 'pgwatch-generated-metric-time-lvl';
1207 `)
1208 r.NoError(err)
1209
1210
1211 var regA0, regB0 *string
1212 r.NoError(conn.QueryRow(ctx, "SELECT to_regclass('public.test_metric_a')::text, to_regclass('public.test_metric_b')::text").Scan(®A0, ®B0))
1213 r.NotNil(regA0, "public.test_metric_a must exist after SyncMetric")
1214 r.NotNil(regB0, "public.test_metric_b must exist after SyncMetric")
1215
1216 var topLevelCount int
1217 r.NoError(conn.QueryRow(ctx, "SELECT count(*) FROM admin.get_top_level_metric_tables() WHERE table_name IN ('test_metric_a', 'test_metric_b')").Scan(&topLevelCount))
1218 a.Equal(2, topLevelCount)
1219
1220
1221 _, err = conn.Exec(ctx, "CALL admin.drop_all_metric_tables();")
1222 r.NoError(err)
1223
1224
1225 var regA, regB *string
1226 r.NoError(conn.QueryRow(ctx, "SELECT to_regclass('public.test_metric_a')::text, to_regclass('public.test_metric_b')::text").Scan(®A, ®B))
1227 a.Nil(regA, "public.test_metric_a should be dropped")
1228 a.Nil(regB, "public.test_metric_b should be dropped")
1229
1230
1231 var subpartCount int
1232 r.NoError(conn.QueryRow(ctx, "SELECT count(*) FROM pg_class WHERE relnamespace = 'subpartitions'::regnamespace").Scan(&subpartCount))
1233 a.Equal(0, subpartCount, "no tables should remain in the subpartitions schema")
1234
1235
1236 var listingCount int
1237 r.NoError(conn.QueryRow(ctx, "SELECT count(*) FROM admin.all_distinct_dbname_metrics").Scan(&listingCount))
1238 a.Equal(0, listingCount)
1239
1240
1241 var prokind string
1242 r.NoError(conn.QueryRow(ctx, `
1243 SELECT p.prokind FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
1244 WHERE n.nspname = 'admin' AND p.proname = 'drop_all_metric_tables'
1245 `).Scan(&prokind))
1246 a.Equal("p", prokind, "admin.drop_all_metric_tables must be a procedure")
1247 }
1248