From 07c3de4939f68276ee1c0c919be0fb9e76818674 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 12 Jun 2026 13:20:27 +0700 Subject: [PATCH] feat(store): add MaintenancePolicy CRUD backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Типы maintenance_policy, методы store.Backend и реализации для PostgreSQL и in-memory. Co-authored-by: Cursor --- .../repository/postgres_maintenance_policy.go | 304 ++++++++++++++++++ internal/store/backend.go | 10 + internal/store/maintenance_policy.go | 89 +++++ internal/store/memory.go | 60 ++-- internal/store/memory_maintenance_policy.go | 244 ++++++++++++++ 5 files changed, 679 insertions(+), 28 deletions(-) create mode 100644 internal/repository/postgres_maintenance_policy.go create mode 100644 internal/store/maintenance_policy.go create mode 100644 internal/store/memory_maintenance_policy.go diff --git a/internal/repository/postgres_maintenance_policy.go b/internal/repository/postgres_maintenance_policy.go new file mode 100644 index 0000000..d1671a5 --- /dev/null +++ b/internal/repository/postgres_maintenance_policy.go @@ -0,0 +1,304 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "evobgp/internal/store" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +const maintenancePolicySelect = ` + SELECT id, name, table_name, condition_sql, retention_period_sec, max_rows, + vacuum_strategy, schedule_cron, enabled, dry_run_enabled, + last_run_at, COALESCE(last_status, ''), COALESCE(last_error, ''), + created_at, updated_at + FROM maintenance_policy` + +func scanMaintenancePolicy(row pgx.Row) (*store.MaintenancePolicy, error) { + var p store.MaintenancePolicy + var retention, maxRows *int32 + var lastRun *time.Time + err := row.Scan( + &p.ID, &p.Name, &p.TableName, &p.Condition, &retention, &maxRows, + &p.VacuumStrategy, &p.Schedule, &p.Enabled, &p.DryRunEnabled, + &lastRun, &p.LastStatus, &p.LastError, &p.CreatedAt, &p.UpdatedAt, + ) + if err != nil { + return nil, err + } + if retention != nil { + v := int(*retention) + p.RetentionPeriodSec = &v + } + if maxRows != nil { + v := int(*maxRows) + p.MaxRows = &v + } + if lastRun != nil { + t := lastRun.UTC() + p.LastRunAt = &t + } + return &p, nil +} + +func (p *Postgres) ListMaintenancePolicies(cursor string, limit int) ([]*store.MaintenancePolicy, string, bool, error) { + if limit <= 0 { + limit = 50 + } + off := 0 + if cursor != "" { + if n, err := strconv.Atoi(cursor); err == nil && n >= 0 { + off = n + } + } + ctx := context.Background() + rows, err := p.pool.Query(ctx, maintenancePolicySelect+` + ORDER BY created_at DESC, id DESC + LIMIT $1 OFFSET $2`, limit+1, off) + if err != nil { + return nil, "", false, err + } + defer rows.Close() + var out []*store.MaintenancePolicy + for rows.Next() { + pol, err := scanMaintenancePolicy(rows) + if err != nil { + continue + } + out = append(out, pol) + } + more := len(out) > limit + if more { + out = out[:limit] + } + next := "" + if more { + next = strconv.Itoa(off + limit) + } + return out, next, more, rows.Err() +} + +func (p *Postgres) GetMaintenancePolicy(id string) (*store.MaintenancePolicy, error) { + ctx := context.Background() + row := p.pool.QueryRow(ctx, maintenancePolicySelect+` WHERE id=$1`, id) + pol, err := scanMaintenancePolicy(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, store.ErrNotFound + } + return nil, err + } + return pol, nil +} + +func (p *Postgres) CreateMaintenancePolicy(in *store.MaintenancePolicy) (*store.MaintenancePolicy, error) { + if in == nil { + return nil, store.ErrInvalidInput + } + vacuum := in.VacuumStrategy + if vacuum == "" { + vacuum = store.VacuumStrategyNone + } + if err := store.ValidateMaintenancePolicyInput(in.Name, in.TableName, vacuum, in.Schedule); err != nil { + return nil, err + } + ctx := context.Background() + id := uuid.NewString() + now := time.Now().UTC() + condition := store.NormalizeMaintenancePolicyCondition(in.Condition) + var retention, maxRows *int32 + if in.RetentionPeriodSec != nil { + v := int32(*in.RetentionPeriodSec) + retention = &v + } + if in.MaxRows != nil { + v := int32(*in.MaxRows) + maxRows = &v + } + _, err := p.pool.Exec(ctx, ` + INSERT INTO maintenance_policy ( + id, name, table_name, condition_sql, retention_period_sec, max_rows, + vacuum_strategy, schedule_cron, enabled, dry_run_enabled, created_at, updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11)`, + id, strings.TrimSpace(in.Name), strings.TrimSpace(in.TableName), condition, + retention, maxRows, vacuum, strings.TrimSpace(in.Schedule), + in.Enabled, in.DryRunEnabled, now) + if err != nil { + return nil, err + } + return p.GetMaintenancePolicy(id) +} + +func (p *Postgres) UpdateMaintenancePolicy(id string, patch *store.MaintenancePolicyPatch) (*store.MaintenancePolicy, error) { + if patch == nil { + return nil, store.ErrInvalidInput + } + cur, err := p.GetMaintenancePolicy(id) + if err != nil { + return nil, err + } + if patch.Name != nil { + cur.Name = strings.TrimSpace(*patch.Name) + } + if patch.TableName != nil { + cur.TableName = strings.TrimSpace(*patch.TableName) + } + if patch.Condition != nil { + cur.Condition = store.NormalizeMaintenancePolicyCondition(*patch.Condition) + } + if patch.RetentionPeriodSec != nil { + cur.RetentionPeriodSec = patch.RetentionPeriodSec + } + if patch.MaxRows != nil { + cur.MaxRows = patch.MaxRows + } + if patch.VacuumStrategy != nil { + if !store.ValidVacuumStrategy(*patch.VacuumStrategy) { + return nil, store.ErrInvalidInput + } + cur.VacuumStrategy = strings.TrimSpace(*patch.VacuumStrategy) + } + if patch.Schedule != nil { + cur.Schedule = strings.TrimSpace(*patch.Schedule) + } + if patch.Enabled != nil { + cur.Enabled = *patch.Enabled + } + if patch.DryRunEnabled != nil { + cur.DryRunEnabled = *patch.DryRunEnabled + } + if err := store.ValidateMaintenancePolicyInput(cur.Name, cur.TableName, cur.VacuumStrategy, cur.Schedule); err != nil { + return nil, err + } + var retention, maxRows *int32 + if cur.RetentionPeriodSec != nil { + v := int32(*cur.RetentionPeriodSec) + retention = &v + } + if cur.MaxRows != nil { + v := int32(*cur.MaxRows) + maxRows = &v + } + ctx := context.Background() + tag, err := p.pool.Exec(ctx, ` + UPDATE maintenance_policy SET + name=$2, table_name=$3, condition_sql=$4, retention_period_sec=$5, max_rows=$6, + vacuum_strategy=$7, schedule_cron=$8, enabled=$9, dry_run_enabled=$10, updated_at=now() + WHERE id=$1`, + id, cur.Name, cur.TableName, cur.Condition, retention, maxRows, + cur.VacuumStrategy, cur.Schedule, cur.Enabled, cur.DryRunEnabled) + if err != nil { + return nil, err + } + if tag.RowsAffected() == 0 { + return nil, store.ErrNotFound + } + return p.GetMaintenancePolicy(id) +} + +func (p *Postgres) DeleteMaintenancePolicy(id string) error { + ctx := context.Background() + tag, err := p.pool.Exec(ctx, `DELETE FROM maintenance_policy WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +func (p *Postgres) TouchMaintenancePolicyRun(id, status, errMsg string) error { + ctx := context.Background() + tag, err := p.pool.Exec(ctx, ` + UPDATE maintenance_policy SET + last_run_at=now(), last_status=$2, last_error=NULLIF($3,''), updated_at=now() + WHERE id=$1`, id, status, errMsg) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +func (p *Postgres) AppendMaintenancePolicyConfigAudit(actor, policyID, action string, before, after map[string]any) error { + ctx := context.Background() + id := uuid.NewString() + var beforeJSON, afterJSON []byte + if before != nil { + beforeJSON, _ = json.Marshal(before) + } + if after != nil { + afterJSON, _ = json.Marshal(after) + } + _, err := p.pool.Exec(ctx, ` + INSERT INTO maintenance_policy_config_audit + (id, policy_id, actor_prefix, action, before_json, after_json, created_at) + VALUES ($1, NULLIF($2,''), $3, $4, $5::jsonb, $6::jsonb, now())`, + id, policyID, strings.TrimSpace(actor), action, + nullJSONBytes(beforeJSON), nullJSONBytes(afterJSON)) + return err +} + +func nullJSONBytes(b []byte) any { + if len(b) == 0 { + return nil + } + return string(b) +} + +func (p *Postgres) ListMaintenancePolicyConfigAudit(cursor string, limit int) ([]*store.MaintenancePolicyConfigAudit, string, bool, error) { + if limit <= 0 { + limit = 50 + } + off := 0 + if cursor != "" { + if n, err := strconv.Atoi(cursor); err == nil && n >= 0 { + off = n + } + } + ctx := context.Background() + rows, err := p.pool.Query(ctx, ` + SELECT id, COALESCE(policy_id,''), actor_prefix, action, + before_json, after_json, created_at + FROM maintenance_policy_config_audit + ORDER BY created_at DESC, id DESC + LIMIT $1 OFFSET $2`, limit+1, off) + if err != nil { + return nil, "", false, err + } + defer rows.Close() + var out []*store.MaintenancePolicyConfigAudit + for rows.Next() { + var r store.MaintenancePolicyConfigAudit + var beforeRaw, afterRaw []byte + if err := rows.Scan(&r.ID, &r.PolicyID, &r.ActorPrefix, &r.Action, &beforeRaw, &afterRaw, &r.CreatedAt); err != nil { + continue + } + if len(beforeRaw) > 0 { + _ = json.Unmarshal(beforeRaw, &r.Before) + } + if len(afterRaw) > 0 { + _ = json.Unmarshal(afterRaw, &r.After) + } + out = append(out, &r) + } + more := len(out) > limit + if more { + out = out[:limit] + } + next := "" + if more { + next = strconv.Itoa(off + limit) + } + return out, next, more, rows.Err() +} diff --git a/internal/store/backend.go b/internal/store/backend.go index 6e7275b..1bdfec3 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -115,6 +115,16 @@ type Backend interface { // RunPeriodicMaintenance prunes stale DB rows (no-op for in-memory). RunPeriodicMaintenance(ctx context.Context) + + // Maintenance policies (instance-scoped PostgreSQL maintenance configuration). + ListMaintenancePolicies(cursor string, limit int) ([]*MaintenancePolicy, string, bool, error) + GetMaintenancePolicy(id string) (*MaintenancePolicy, error) + CreateMaintenancePolicy(in *MaintenancePolicy) (*MaintenancePolicy, error) + UpdateMaintenancePolicy(id string, patch *MaintenancePolicyPatch) (*MaintenancePolicy, error) + DeleteMaintenancePolicy(id string) error + TouchMaintenancePolicyRun(id, status, errMsg string) error + AppendMaintenancePolicyConfigAudit(actor, policyID, action string, before, after map[string]any) error + ListMaintenancePolicyConfigAudit(cursor string, limit int) ([]*MaintenancePolicyConfigAudit, string, bool, error) } // ASNPrefixCacheEntry is a cached RIPEstat response for one ASN. diff --git a/internal/store/maintenance_policy.go b/internal/store/maintenance_policy.go new file mode 100644 index 0000000..334502e --- /dev/null +++ b/internal/store/maintenance_policy.go @@ -0,0 +1,89 @@ +package store + +import ( + "strings" + "time" +) + +// Vacuum strategy values for maintenance_policy.vacuum_strategy. +const ( + VacuumStrategyNone = "none" + VacuumStrategyVacuum = "vacuum" + VacuumStrategyAnalyze = "analyze" + VacuumStrategyVacuumAnalyze = "vacuum_analyze" + VacuumStrategyReindex = "reindex" +) + +// MaintenancePolicy is an instance-scoped PostgreSQL maintenance policy (control plane DB). +type MaintenancePolicy struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + TableName string `json:"table_name"` + Condition string `json:"condition"` + RetentionPeriodSec *int `json:"retention_period_sec,omitempty"` + MaxRows *int `json:"max_rows,omitempty"` + VacuumStrategy string `json:"vacuum_strategy"` + Schedule string `json:"schedule"` + Enabled bool `json:"enabled"` + DryRunEnabled bool `json:"dry_run_enabled"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + LastStatus string `json:"last_status,omitempty"` + LastError string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// MaintenancePolicyPatch is a partial update for maintenance_policy. +type MaintenancePolicyPatch struct { + Name *string `json:"name,omitempty"` + TableName *string `json:"table_name,omitempty"` + Condition *string `json:"condition,omitempty"` + RetentionPeriodSec *int `json:"retention_period_sec,omitempty"` + MaxRows *int `json:"max_rows,omitempty"` + VacuumStrategy *string `json:"vacuum_strategy,omitempty"` + Schedule *string `json:"schedule,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + DryRunEnabled *bool `json:"dry_run_enabled,omitempty"` +} + +// MaintenancePolicyConfigAudit is a configuration change log entry. +type MaintenancePolicyConfigAudit struct { + ID string `json:"id"` + PolicyID string `json:"policy_id,omitempty"` + ActorPrefix string `json:"actor_prefix"` + Action string `json:"action"` + Before map[string]any `json:"before,omitempty"` + After map[string]any `json:"after,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ValidVacuumStrategy reports whether s is an allowed vacuum_strategy value. +func ValidVacuumStrategy(s string) bool { + switch strings.TrimSpace(s) { + case VacuumStrategyNone, VacuumStrategyVacuum, VacuumStrategyAnalyze, + VacuumStrategyVacuumAnalyze, VacuumStrategyReindex: + return true + default: + return false + } +} + +// NormalizeMaintenancePolicyCondition returns a safe default WHERE fragment. +func NormalizeMaintenancePolicyCondition(condition string) string { + c := strings.TrimSpace(condition) + if c == "" { + return "true" + } + return c +} + +// ValidateMaintenancePolicyInput checks required fields for create/update payloads. +func ValidateMaintenancePolicyInput(name, tableName, vacuumStrategy, schedule string) error { + if strings.TrimSpace(name) == "" || strings.TrimSpace(tableName) == "" || strings.TrimSpace(schedule) == "" { + return ErrInvalidInput + } + if !ValidVacuumStrategy(vacuumStrategy) { + return ErrInvalidInput + } + return nil +} diff --git a/internal/store/memory.go b/internal/store/memory.go index 8aa1955..5d9a5cc 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -33,17 +33,19 @@ type Memory struct { peers map[string]*BGPPeer - dohProfiles map[string]*DohProfile - communities map[string]*Community - cdnSources map[string]*CDNSource - asEntries map[string]*ASEntry - domainEnt map[string]*DomainEntry - ipRanges map[string]*IPRangeEntry - settings map[string]map[string]any // tenantID -> key -> JSON-compatible value - revPrefixes map[string][]PrefixRow - moduleSnapshots map[string]*moduleSnapshotRec - asnPrefixCache map[int64]*ASNPrefixCacheEntry - apiKeys map[string]*apiKeyRec + dohProfiles map[string]*DohProfile + communities map[string]*Community + cdnSources map[string]*CDNSource + asEntries map[string]*ASEntry + domainEnt map[string]*DomainEntry + ipRanges map[string]*IPRangeEntry + settings map[string]map[string]any // tenantID -> key -> JSON-compatible value + revPrefixes map[string][]PrefixRow + moduleSnapshots map[string]*moduleSnapshotRec + asnPrefixCache map[int64]*ASNPrefixCacheEntry + apiKeys map[string]*apiKeyRec + maintenancePolicies map[string]*MaintenancePolicy + maintConfigAudit []*MaintenancePolicyConfigAudit // DemoIDs valid after SeedDemo() demoTenantID string @@ -123,23 +125,25 @@ type Speaker struct { func NewMemory() *Memory { return &Memory{ - tenants: make(map[string]*Tenant), - modules: make(map[string]*Module), - revisions: make(map[string]*Revision), - speakers: make(map[string]*Speaker), - publishedRevision: make(map[string]publishedInfo), - peers: make(map[string]*BGPPeer), - dohProfiles: make(map[string]*DohProfile), - communities: make(map[string]*Community), - cdnSources: make(map[string]*CDNSource), - asEntries: make(map[string]*ASEntry), - domainEnt: make(map[string]*DomainEntry), - ipRanges: make(map[string]*IPRangeEntry), - settings: make(map[string]map[string]any), - revPrefixes: make(map[string][]PrefixRow), - moduleSnapshots: make(map[string]*moduleSnapshotRec), - asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry), - apiKeys: make(map[string]*apiKeyRec), + tenants: make(map[string]*Tenant), + modules: make(map[string]*Module), + revisions: make(map[string]*Revision), + speakers: make(map[string]*Speaker), + publishedRevision: make(map[string]publishedInfo), + peers: make(map[string]*BGPPeer), + dohProfiles: make(map[string]*DohProfile), + communities: make(map[string]*Community), + cdnSources: make(map[string]*CDNSource), + asEntries: make(map[string]*ASEntry), + domainEnt: make(map[string]*DomainEntry), + ipRanges: make(map[string]*IPRangeEntry), + settings: make(map[string]map[string]any), + revPrefixes: make(map[string][]PrefixRow), + moduleSnapshots: make(map[string]*moduleSnapshotRec), + asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry), + apiKeys: make(map[string]*apiKeyRec), + maintenancePolicies: make(map[string]*MaintenancePolicy), + maintConfigAudit: nil, } } diff --git a/internal/store/memory_maintenance_policy.go b/internal/store/memory_maintenance_policy.go new file mode 100644 index 0000000..ccdc0e3 --- /dev/null +++ b/internal/store/memory_maintenance_policy.go @@ -0,0 +1,244 @@ +package store + +import ( + "sort" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +func (m *Memory) ListMaintenancePolicies(cursor string, limit int) ([]*MaintenancePolicy, string, bool, error) { + if limit <= 0 { + limit = 50 + } + m.mu.RLock() + defer m.mu.RUnlock() + all := make([]*MaintenancePolicy, 0, len(m.maintenancePolicies)) + for _, p := range m.maintenancePolicies { + all = append(all, p) + } + sort.Slice(all, func(i, j int) bool { + if all[i].CreatedAt.Equal(all[j].CreatedAt) { + return all[i].ID > all[j].ID + } + return all[i].CreatedAt.After(all[j].CreatedAt) + }) + off := parseMaintCursor(cursor) + end := off + limit + next := "" + hasMore := false + if end > len(all) { + end = len(all) + } else if end < len(all) { + hasMore = true + next = formatMaintCursor(end) + } + if off >= len(all) { + return nil, "", false, nil + } + out := make([]*MaintenancePolicy, end-off) + copy(out, all[off:end]) + return out, next, hasMore, nil +} + +func (m *Memory) GetMaintenancePolicy(id string) (*MaintenancePolicy, error) { + m.mu.RLock() + defer m.mu.RUnlock() + p, ok := m.maintenancePolicies[id] + if !ok { + return nil, ErrNotFound + } + return cloneMaintenancePolicy(p), nil +} + +func (m *Memory) CreateMaintenancePolicy(in *MaintenancePolicy) (*MaintenancePolicy, error) { + if in == nil { + return nil, ErrInvalidInput + } + vacuum := in.VacuumStrategy + if vacuum == "" { + vacuum = VacuumStrategyNone + } + if err := ValidateMaintenancePolicyInput(in.Name, in.TableName, vacuum, in.Schedule); err != nil { + return nil, err + } + m.mu.Lock() + defer m.mu.Unlock() + now := time.Now().UTC() + id := uuid.NewString() + p := &MaintenancePolicy{ + ID: id, + Name: strings.TrimSpace(in.Name), + TableName: strings.TrimSpace(in.TableName), + Condition: NormalizeMaintenancePolicyCondition(in.Condition), + RetentionPeriodSec: in.RetentionPeriodSec, + MaxRows: in.MaxRows, + VacuumStrategy: vacuum, + Schedule: strings.TrimSpace(in.Schedule), + Enabled: in.Enabled, + DryRunEnabled: in.DryRunEnabled, + CreatedAt: now, + UpdatedAt: now, + } + m.maintenancePolicies[id] = p + return cloneMaintenancePolicy(p), nil +} + +func (m *Memory) UpdateMaintenancePolicy(id string, patch *MaintenancePolicyPatch) (*MaintenancePolicy, error) { + if patch == nil { + return nil, ErrInvalidInput + } + m.mu.Lock() + defer m.mu.Unlock() + p, ok := m.maintenancePolicies[id] + if !ok { + return nil, ErrNotFound + } + if patch.Name != nil { + p.Name = strings.TrimSpace(*patch.Name) + } + if patch.TableName != nil { + p.TableName = strings.TrimSpace(*patch.TableName) + } + if patch.Condition != nil { + p.Condition = NormalizeMaintenancePolicyCondition(*patch.Condition) + } + if patch.RetentionPeriodSec != nil { + p.RetentionPeriodSec = patch.RetentionPeriodSec + } + if patch.MaxRows != nil { + p.MaxRows = patch.MaxRows + } + if patch.VacuumStrategy != nil { + if !ValidVacuumStrategy(*patch.VacuumStrategy) { + return nil, ErrInvalidInput + } + p.VacuumStrategy = strings.TrimSpace(*patch.VacuumStrategy) + } + if patch.Schedule != nil { + p.Schedule = strings.TrimSpace(*patch.Schedule) + } + if patch.Enabled != nil { + p.Enabled = *patch.Enabled + } + if patch.DryRunEnabled != nil { + p.DryRunEnabled = *patch.DryRunEnabled + } + if err := ValidateMaintenancePolicyInput(p.Name, p.TableName, p.VacuumStrategy, p.Schedule); err != nil { + return nil, err + } + p.UpdatedAt = time.Now().UTC() + return cloneMaintenancePolicy(p), nil +} + +func (m *Memory) DeleteMaintenancePolicy(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.maintenancePolicies[id]; !ok { + return ErrNotFound + } + delete(m.maintenancePolicies, id) + return nil +} + +func (m *Memory) TouchMaintenancePolicyRun(id, status, errMsg string) error { + m.mu.Lock() + defer m.mu.Unlock() + p, ok := m.maintenancePolicies[id] + if !ok { + return ErrNotFound + } + now := time.Now().UTC() + p.LastRunAt = &now + p.LastStatus = status + p.LastError = errMsg + p.UpdatedAt = now + return nil +} + +func (m *Memory) AppendMaintenancePolicyConfigAudit(actor, policyID, action string, before, after map[string]any) error { + m.mu.Lock() + defer m.mu.Unlock() + row := &MaintenancePolicyConfigAudit{ + ID: uuid.NewString(), + PolicyID: policyID, + ActorPrefix: strings.TrimSpace(actor), + Action: action, + Before: before, + After: after, + CreatedAt: time.Now().UTC(), + } + m.maintConfigAudit = append(m.maintConfigAudit, row) + return nil +} + +func (m *Memory) ListMaintenancePolicyConfigAudit(cursor string, limit int) ([]*MaintenancePolicyConfigAudit, string, bool, error) { + if limit <= 0 { + limit = 50 + } + m.mu.RLock() + defer m.mu.RUnlock() + all := append([]*MaintenancePolicyConfigAudit(nil), m.maintConfigAudit...) + sort.Slice(all, func(i, j int) bool { + if all[i].CreatedAt.Equal(all[j].CreatedAt) { + return all[i].ID > all[j].ID + } + return all[i].CreatedAt.After(all[j].CreatedAt) + }) + off := parseMaintCursor(cursor) + end := off + limit + next := "" + hasMore := false + if end > len(all) { + end = len(all) + } else if end < len(all) { + hasMore = true + next = formatMaintCursor(end) + } + if off >= len(all) { + return nil, "", false, nil + } + out := make([]*MaintenancePolicyConfigAudit, end-off) + copy(out, all[off:end]) + return out, next, hasMore, nil +} + +func cloneMaintenancePolicy(p *MaintenancePolicy) *MaintenancePolicy { + if p == nil { + return nil + } + cp := *p + if p.RetentionPeriodSec != nil { + v := *p.RetentionPeriodSec + cp.RetentionPeriodSec = &v + } + if p.MaxRows != nil { + v := *p.MaxRows + cp.MaxRows = &v + } + if p.LastRunAt != nil { + t := *p.LastRunAt + cp.LastRunAt = &t + } + return &cp +} + +func parseMaintCursor(cursor string) int { + if cursor == "" { + return 0 + } + var off int + for _, r := range cursor { + if r < '0' || r > '9' { + return 0 + } + off = off*10 + int(r-'0') + } + return off +} + +func formatMaintCursor(off int) string { + return strconv.Itoa(off) +}