Files
silo-server/internal/partman/partman.go
T

242 lines
5.8 KiB
Go

package partman
import (
"context"
"fmt"
"regexp"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
const deleteBatchSize = 10000
var partitionBoundsRE = regexp.MustCompile(`FROM \('([^']+)'\) TO \('([^']+)'\)`)
type Granularity int
const (
Daily Granularity = iota + 1
Weekly
)
type Manager struct {
pool *pgxpool.Pool
table string
granularity Granularity
createAhead int
}
func NewManager(pool *pgxpool.Pool, table string, granularity Granularity, createAhead int) *Manager {
return &Manager{
pool: pool,
table: table,
granularity: granularity,
createAhead: createAhead,
}
}
func (m *Manager) EnsureFuturePartitions(ctx context.Context) error {
if m == nil {
return nil
}
start := m.granularity.truncate(time.Now().UTC())
for i := 0; i <= m.createAhead; i++ {
lower := m.granularity.addPeriods(start, i)
upper := m.granularity.next(lower)
name := m.partitionName(lower)
if _, err := m.pool.Exec(ctx, fmt.Sprintf(
`CREATE TABLE IF NOT EXISTS public.%s PARTITION OF public.%s FOR VALUES FROM (%s) TO (%s)`,
quoteIdent(name),
quoteIdent(m.table),
quoteLiteralTimestamp(lower),
quoteLiteralTimestamp(upper),
)); err != nil {
return fmt.Errorf("create partition %s: %w", name, err)
}
}
return nil
}
func (m *Manager) DropExpiredPartitions(ctx context.Context, cutoff time.Time) ([]string, error) {
if m == nil {
return nil, nil
}
type partitionInfo struct {
name string
upper time.Time
}
rows, err := m.pool.Query(ctx, `
SELECT child.relname, pg_get_expr(child.relpartbound, child.oid)
FROM pg_inherits
JOIN pg_class parent ON parent.oid = pg_inherits.inhparent
JOIN pg_class child ON child.oid = pg_inherits.inhrelid
JOIN pg_namespace ns ON ns.oid = child.relnamespace
WHERE parent.relname = $1
AND ns.nspname = 'public'
`, m.table)
if err != nil {
return nil, fmt.Errorf("query partitions for %s: %w", m.table, err)
}
defer rows.Close()
var partitions []partitionInfo
for rows.Next() {
var name string
var bound string
if err := rows.Scan(&name, &bound); err != nil {
return nil, fmt.Errorf("scan partition metadata for %s: %w", m.table, err)
}
if strings.Contains(bound, "DEFAULT") {
continue
}
upper, err := parsePartitionUpperBound(bound)
if err != nil {
return nil, fmt.Errorf("parse bound for %s: %w", name, err)
}
if !upper.After(cutoff.UTC()) {
partitions = append(partitions, partitionInfo{name: name, upper: upper})
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate partitions for %s: %w", m.table, err)
}
sort.Slice(partitions, func(i, j int) bool {
return partitions[i].upper.Before(partitions[j].upper)
})
dropped := make([]string, 0, len(partitions))
for _, partition := range partitions {
if _, err := m.pool.Exec(ctx, fmt.Sprintf(`DROP TABLE public.%s`, quoteIdent(partition.name))); err != nil {
return dropped, fmt.Errorf("drop partition %s: %w", partition.name, err)
}
dropped = append(dropped, partition.name)
}
return dropped, nil
}
func (m *Manager) DeleteExpiredRowsFromDefault(ctx context.Context, cutoff time.Time) (int64, error) {
if m == nil {
return 0, nil
}
defaultTable := m.defaultPartitionName()
var exists bool
if err := m.pool.QueryRow(ctx, `SELECT to_regclass($1) IS NOT NULL`, "public."+defaultTable).Scan(&exists); err != nil {
return 0, fmt.Errorf("check default partition for %s: %w", m.table, err)
}
if !exists {
return 0, nil
}
totalDeleted := int64(0)
for {
tag, err := m.pool.Exec(ctx, fmt.Sprintf(`
WITH doomed AS (
SELECT ctid
FROM public.%s
WHERE "timestamp" < $1
LIMIT $2
)
DELETE FROM public.%s
WHERE ctid IN (SELECT ctid FROM doomed)
`, quoteIdent(defaultTable), quoteIdent(defaultTable)), cutoff.UTC(), deleteBatchSize)
if err != nil {
return totalDeleted, fmt.Errorf("delete expired rows from %s: %w", defaultTable, err)
}
deleted := tag.RowsAffected()
totalDeleted += deleted
if deleted < deleteBatchSize {
return totalDeleted, nil
}
}
}
func (m *Manager) partitionName(lower time.Time) string {
return fmt.Sprintf("%s_p_%s", m.table, lower.UTC().Format("20060102"))
}
func (m *Manager) defaultPartitionName() string {
return m.table + "_default"
}
func (g Granularity) truncate(t time.Time) time.Time {
u := t.UTC()
day := time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
switch g {
case Daily:
return day
case Weekly:
offset := int(day.Weekday())
if offset == 0 {
offset = 7
}
return day.AddDate(0, 0, -(offset - 1))
default:
return day
}
}
func (g Granularity) next(t time.Time) time.Time {
switch g {
case Weekly:
return t.UTC().AddDate(0, 0, 7)
default:
return t.UTC().AddDate(0, 0, 1)
}
}
func (g Granularity) addPeriods(t time.Time, periods int) time.Time {
switch g {
case Weekly:
return t.UTC().AddDate(0, 0, 7*periods)
default:
return t.UTC().AddDate(0, 0, periods)
}
}
func parsePartitionUpperBound(bound string) (time.Time, error) {
matches := partitionBoundsRE.FindStringSubmatch(bound)
if len(matches) != 3 {
return time.Time{}, fmt.Errorf("unrecognized partition bound: %q", bound)
}
return parseBoundTimestamp(matches[2])
}
func parseBoundTimestamp(value string) (time.Time, error) {
layouts := []string{
"2006-01-02 15:04:05.999999999-07",
"2006-01-02 15:04:05.999999-07",
"2006-01-02 15:04:05-07",
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999-07:00",
"2006-01-02 15:04:05-07:00",
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, value)
if err == nil {
return parsed.UTC(), nil
}
}
return time.Time{}, fmt.Errorf("parse timestamp %q", value)
}
func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
func quoteLiteralTimestamp(t time.Time) string {
return "'" + t.UTC().Format("2006-01-02 15:04:05-07") + "'"
}