Skip to content

Commit 26725e9

Browse files
committed
Move Postgres log rotation parameters to the postgres package
The behavior of these parameters is independent of OpenTelemetry. This refactor is part of a larger series to make the Postgres log directory configurable.
1 parent d259cde commit 26725e9

File tree

3 files changed

+157
-46
lines changed

3 files changed

+157
-46
lines changed

internal/collector/postgres.go

Lines changed: 7 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
_ "embed"
1010
"encoding/json"
1111
"fmt"
12-
"math"
1312
"slices"
1413
"time"
1514

@@ -108,21 +107,17 @@ func EnablePostgresLogging(
108107
if spec != nil && spec.RetentionPeriod != nil {
109108
retentionPeriod = spec.RetentionPeriod.AsDuration()
110109
}
111-
logFilename, logRotationAge := generateLogFilenameAndRotationAge(retentionPeriod)
112110

113-
// NOTE: The automated portions of log_filename are *entirely* based
114-
// on time. There is no spelling that is guaranteed to be unique or
115-
// monotonically increasing.
111+
// Rotate log files according to retention.
116112
//
117-
// TODO(logs): Limit the size/bytes of logs without losing messages;
118-
// probably requires another process that deletes the oldest files.
113+
// The ".log" suffix is replaced by ".csv" for CSV log files, and
114+
// the ".log" suffix is replaced by ".json" for JSON log files.
119115
//
120-
// The ".log" suffix is replaced by ".json" for JSON log files.
121-
outParameters.Add("log_filename", logFilename)
116+
// https://www.postgresql.org/docs/current/runtime-config-logging.html
117+
for k, v := range postgres.LogRotation(retentionPeriod, "postgresql-", ".log") {
118+
outParameters.Add(k, v)
119+
}
122120
outParameters.Add("log_file_mode", "0660")
123-
outParameters.Add("log_rotation_age", logRotationAge)
124-
outParameters.Add("log_rotation_size", "0")
125-
outParameters.Add("log_truncate_on_rotation", "on")
126121

127122
// Log in a timezone that the OpenTelemetry Collector will understand.
128123
outParameters.Add("log_timezone", "UTC")
@@ -300,37 +295,3 @@ func EnablePostgresLogging(
300295
}
301296
}
302297
}
303-
304-
// generateLogFilenameAndRotationAge takes a retentionPeriod and returns a
305-
// log_filename and log_rotation_age to be used to configure postgres logging
306-
func generateLogFilenameAndRotationAge(
307-
retentionPeriod metav1.Duration,
308-
) (logFilename, logRotationAge string) {
309-
// Given how postgres does its log rotation with the truncate feature, we
310-
// will always need to make up the total retention period with multiple log
311-
// files that hold subunits of the total time (e.g. if the retentionPeriod
312-
// is an hour, there will be 60 1-minute long files; if the retentionPeriod
313-
// is a day, there will be 24 1-hour long files, etc)
314-
315-
hours := math.Ceil(retentionPeriod.Hours())
316-
317-
switch true {
318-
case hours <= 1: // One hour's worth of logs in 60 minute long log files
319-
logFilename = "postgresql-%M.log"
320-
logRotationAge = "1min"
321-
case hours <= 24: // One day's worth of logs in 24 hour long log files
322-
logFilename = "postgresql-%H.log"
323-
logRotationAge = "1h"
324-
case hours <= 24*7: // One week's worth of logs in 7 day long log files
325-
logFilename = "postgresql-%a.log"
326-
logRotationAge = "1d"
327-
case hours <= 24*28: // One month's worth of logs in 28-31 day long log files
328-
logFilename = "postgresql-%d.log"
329-
logRotationAge = "1d"
330-
default: // One year's worth of logs in 365 day long log files
331-
logFilename = "postgresql-%j.log"
332-
logRotationAge = "1d"
333-
}
334-
335-
return
336-
}

internal/postgres/config.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ package postgres
77
import (
88
"context"
99
"fmt"
10+
"math"
1011
"strings"
1112

1213
corev1 "k8s.io/api/core/v1"
14+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1315

1416
"github.com/crunchydata/postgres-operator/internal/config"
1517
"github.com/crunchydata/postgres-operator/internal/feature"
@@ -96,6 +98,71 @@ func LogDirectory() string {
9698
return fmt.Sprintf("%s/logs/postgres", dataMountPath)
9799
}
98100

101+
// LogRotation returns parameters that rotate log files while keeping a minimum amount.
102+
// Postgres truncates and reuses log files after that minimum amount.
103+
// Log file names start with filePrefix and end with fileSuffix.
104+
//
105+
// NOTE: These parameters do *not* enable logging to files. Set "logging_collector" for that.
106+
func LogRotation(minimum metav1.Duration, filePrefix, fileSuffix string) map[string]string {
107+
hours := math.Ceil(minimum.Hours())
108+
109+
// The "log_filename" parameter is interpreted similar to `strftime`;
110+
// escape percent U+0025 by doubling it.
111+
// - https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-FILENAME
112+
prefix := strings.ReplaceAll(filePrefix, "%", "%%")
113+
suffix := strings.ReplaceAll(fileSuffix, "%", "%%")
114+
115+
// Postgres can "rotate" its own log files by calculating log_filename as needed.
116+
// However, the automated portions of log_filename are *entirely* based on time.
117+
// An inappropriate pairing of log_filename with other logging parameters could lose log messages.
118+
//
119+
// TODO(logs): Limit the size/bytes of logs without losing messages;
120+
// probably requires another process that deletes the oldest files. TODO(sidecar)
121+
//
122+
// The parameter combinations below have Postgres discard log messages and reuse log files
123+
// only after the minimum time has elapsed.
124+
125+
result := map[string]string{
126+
// Discard old messages when log_filename is reused due to rotation.
127+
"log_truncate_on_rotation": "on",
128+
129+
// To not lose messages, log_rotation_size must be larger than the volume of messages emitted before log_filename changes.
130+
// Rather than monitor and accommodate that, disable rotation by size completely.
131+
"log_rotation_size": "0",
132+
}
133+
134+
// These pairings have Postgres log to multiple files so a log consumer
135+
// has the opportunity to read a prior file while Postgres truncates the next.
136+
switch {
137+
case hours <= 1:
138+
// One hour of logs in minute-long files
139+
result["log_filename"] = prefix + "%M" + suffix
140+
result["log_rotation_age"] = "1min"
141+
142+
case hours <= 24:
143+
// One day of logs in hour-long files
144+
result["log_filename"] = prefix + "%H" + suffix
145+
result["log_rotation_age"] = "1h"
146+
147+
case hours <= 24*7:
148+
// One week of logs in day-long files
149+
result["log_filename"] = prefix + "%a" + suffix
150+
result["log_rotation_age"] = "1d"
151+
152+
case hours <= 24*28:
153+
// One month of logs in day-long files
154+
result["log_filename"] = prefix + "%d" + suffix
155+
result["log_rotation_age"] = "1d"
156+
157+
default:
158+
// One year of logs in day-long files
159+
result["log_filename"] = prefix + "%j" + suffix
160+
result["log_rotation_age"] = "1d"
161+
}
162+
163+
return result
164+
}
165+
99166
// WALDirectory returns the absolute path to the directory where an instance
100167
// stores its WAL files.
101168
// - https://www.postgresql.org/docs/current/wal.html

internal/postgres/config_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ import (
99
"context"
1010
"errors"
1111
"fmt"
12+
"math/rand/v2"
1213
"os"
1314
"os/exec"
1415
"path/filepath"
1516
"strings"
1617
"testing"
18+
"time"
1719

1820
"gotest.tools/v3/assert"
21+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1922
"sigs.k8s.io/yaml"
2023

2124
"github.com/crunchydata/postgres-operator/internal/testing/cmp"
@@ -37,6 +40,86 @@ func TestDataDirectory(t *testing.T) {
3740
assert.Equal(t, DataDirectory(cluster), "/pgdata/pg12")
3841
}
3942

43+
func TestLogRotation(t *testing.T) {
44+
t.Parallel()
45+
46+
const Day = 24 * time.Hour
47+
48+
random := func(low, high time.Duration) time.Duration {
49+
return low + rand.N(high-low)
50+
}
51+
52+
for _, tt := range []struct {
53+
duration time.Duration
54+
prefix string
55+
suffix string
56+
expected map[string]string
57+
}{
58+
// Small duration becomes one hour split into minutes
59+
{duration: random(1, time.Hour),
60+
expected: map[string]string{
61+
"log_filename": "%M", // two-digit minute [00, 59]
62+
"log_rotation_age": "1min", // × 1 minute = 1 hour
63+
"log_rotation_size": "0",
64+
"log_truncate_on_rotation": "on",
65+
}},
66+
67+
// More than an hour becomes one day split into hours
68+
{duration: random(90*time.Minute, 24*time.Hour),
69+
expected: map[string]string{
70+
"log_filename": "%H", // two-digit hour [00,23]
71+
"log_rotation_age": "1h", // × 1 hour = 1 day
72+
"log_rotation_size": "0",
73+
"log_truncate_on_rotation": "on",
74+
}},
75+
76+
// More than one day becomes one week split into days
77+
{duration: random(3*Day, 7*Day),
78+
expected: map[string]string{
79+
"log_filename": "%a", // locale weekday name
80+
"log_rotation_age": "1d", // × 1 day = 1 week
81+
"log_rotation_size": "0",
82+
"log_truncate_on_rotation": "on",
83+
}},
84+
85+
// More than one week becomes one month split into days
86+
{duration: random(11*Day, 25*Day),
87+
expected: map[string]string{
88+
"log_filename": "%d", // two-digit day of the month [01, 31]
89+
"log_rotation_age": "1d", // × 1 day = 1 month
90+
"log_rotation_size": "0",
91+
"log_truncate_on_rotation": "on",
92+
}},
93+
94+
// More than one month becomes one year split into days
95+
{duration: random(70*Day, 300*Day),
96+
expected: map[string]string{
97+
"log_filename": "%j", // three-digit day of the year [001, 366]
98+
"log_rotation_age": "1d", // × 1 day = 1 year
99+
"log_rotation_size": "0",
100+
"log_truncate_on_rotation": "on",
101+
}},
102+
} {
103+
t.Run(tt.duration.String(), func(t *testing.T) {
104+
actual := LogRotation(metav1.Duration{Duration: tt.duration}, tt.prefix, tt.suffix)
105+
assert.DeepEqual(t, tt.expected, actual)
106+
})
107+
}
108+
109+
t.Run("Escaping", func(t *testing.T) {
110+
// any duration
111+
duration := metav1.Duration{Duration: random(1, 350*Day)}
112+
113+
// double-percent prefix
114+
assert.Assert(t, cmp.Regexp(`^as%%ddf%[^%]qwerty$`,
115+
LogRotation(duration, "as%ddf", "qwerty")["log_filename"]))
116+
117+
// double-percent suffix
118+
assert.Assert(t, cmp.Regexp(`^postgres-%[^%]-x%%y%%zzz$`,
119+
LogRotation(duration, "postgres-", "-x%y%zzz")["log_filename"]))
120+
})
121+
}
122+
40123
func TestWALDirectory(t *testing.T) {
41124
cluster := new(v1beta1.PostgresCluster)
42125
cluster.Spec.PostgresVersion = 13

0 commit comments

Comments
 (0)