reviewalert

package
v1.131.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

Documentation

Overview

Package reviewalert pushes an operator alert when a review queue crosses its staleness threshold (#803, extended to managed scripts by #1287).

#764 made knowledge review debt visible to anyone who looks: bulk_review, platform_info, and the portal all report the pending count and its age. This package supplies the signal for everyone who does not look. It reads a lightweight rollup on a timer and, when the queue crosses the operator's threshold, enqueues a digest through the notification substrate (pkg/notification) rather than sending mail of its own.

Two queues are watched by one mechanism rather than by two copies of it: the knowledge insight queue and the managed-script review queue. What differs between them is named by a Target (which settings section holds the configuration, which state row records the last alert, what the email says and where it points) and read by a Source (what is pending, and how old the oldest is). Everything else — the threshold model, the recipient list, the cooldown, and the single-winner claim — is one implementation.

Three pieces, none of which the substrate already owned:

Settings   the operator's threshold, cooldown, and recipient list,
           stored in the platform_settings section the admin API writes
StateStore the last-alert marker, claimed with one conditional UPDATE so
           a persistently stale queue alerts once per cooldown and only
           one replica's check wins a given window
Checker    the timer that joins them

It must not import pkg/platform: the HTTP composition root supplies the queue sources, the enqueuer, and the portal base URL it holds already.

Index

Constants

View Source
const (
	// NoRecipientsWarning is reported when the check is on with an empty
	// recipient list.
	NoRecipientsWarning = "no recipients are configured, so no alert will be delivered; add at least one address"
	// NoThresholdWarning is reported when the check is on with both
	// thresholds cleared.
	NoThresholdWarning = "both thresholds are 0, so nothing can cross; set a pending count, an age in days, or both"
)

Warnings reported by SettingsView for a configuration that saves cleanly but delivers nothing. They are warnings rather than validation errors so an operator can enable the check and fill in the rest, in either order.

View Source
const (
	// DefaultCooldownHours is the default minimum gap between two alerts
	// about a queue that stays over threshold.
	DefaultCooldownHours = 24
	// DefaultKnowledgeOldestDays is the knowledge queue's default age
	// threshold, in days. It matches the platform's existing definition of
	// stale review debt (#764) rather than inventing a second number, so the
	// alert fires on exactly the rows the portal already badges.
	DefaultKnowledgeOldestDays = knowledgekit.PendingStalenessThresholdDays
)

Default threshold values.

View Source
const DefaultInterval = time.Hour

DefaultInterval is how often a queue is evaluated. The thresholds are measured in days, so an hourly check is already far finer than the signal they watch; the cooldown, not the interval, decides how often mail goes out.

View Source
const (
	// MaxRecipients caps the alert's distribution list. This is an operator
	// alert about a review queue, not an announcement channel.
	MaxRecipients = 20
)

Input bounds. They exist to keep a typo from turning the alert into a mail loop or an unreadable configuration, not to express policy.

Variables

View Source
var ErrNotFound = errors.New("reviewalert: settings not found")

ErrNotFound is returned by the settings store when the alert has never been configured. Callers apply the target's defaults instead.

Functions

This section is empty.

Types

type Checker

type Checker struct {
	// contains filtered or unexported fields
}

Checker evaluates one pending review queue on a timer and alerts the configured recipients when it crosses the threshold.

func New

func New(cfg Config) *Checker

New builds a Checker, or nil when a dependency is absent (no database, or notifications disabled). A nil Checker's methods are no-ops, so the caller brackets Start/Stop unconditionally.

func (*Checker) Check

func (c *Checker) Check(ctx context.Context) error

Check evaluates the queue once and enqueues the alert when it has crossed the threshold and the cooldown allows. It is the whole behavior of the package; Start only supplies the clock.

func (*Checker) Start

func (c *Checker) Start(ctx context.Context)

Start runs the check loop until ctx is canceled or Stop is called. The first check runs one interval in rather than at startup, keeping it clear of boot: the signal it watches is measured in days, so nothing is lost by waiting. What bounds repeat mail is the cooldown claim, which is in the database and so survives a restart. Nil-safe.

func (*Checker) Stop

func (c *Checker) Stop()

Stop ends the check loop and waits for an in-flight check. Nil-safe and idempotent.

type Config

type Config struct {
	// Target names the queue being watched.
	Target Target
	// Settings holds the operator's threshold and recipients.
	Settings SettingsStore
	// State holds the re-alert marker.
	State StateStore
	// Source reports what is pending.
	Source Source
	// Enqueuer is the notification substrate's trigger-side entry point.
	Enqueuer *notification.Enqueuer
	// BaseURL is the portal's public base URL, for the alert's deep link.
	BaseURL string
	// Interval overrides DefaultInterval. Testing hook.
	Interval time.Duration
	// Now overrides time.Now. Testing hook.
	Now func() time.Time
}

Config carries the checker's dependencies. All are required; New returns nil when any is missing, which is how the composition root expresses "this deployment has no database" without a second flag.

type InsightSource added in v1.121.0

type InsightSource struct {
	Insights knowledgekit.InsightStore
}

InsightSource reports the knowledge insight review queue through the same fast-path rollup the portal and platform_info read (#764), so the alert fires on exactly the rows those surfaces already badge.

func (InsightSource) Pending added in v1.121.0

Pending returns the insight queue's rollup.

type PostgresStore

type PostgresStore struct {
	// contains filtered or unexported fields
}

PostgresStore is one queue's PostgreSQL persistence: the operator's configuration (SettingsStore, here) and the re-alert marker (StateStore, in state.go). One store because they are one queue's state, always built together over the same pool; two interfaces because the admin API and the checker each need only their half.

It is bound to a Target rather than to a hardcoded section and row, which is what lets a second review queue reuse this implementation instead of copying it.

func NewPostgresStore

func NewPostgresStore(db *sql.DB, target Target) *PostgresStore

NewPostgresStore creates the PostgreSQL-backed alert store for one queue.

func (*PostgresStore) ClaimAlert

func (s *PostgresStore) ClaimAlert(ctx context.Context, cooldown time.Duration, now time.Time) (bool, error)

ClaimAlert stamps a new alert when the cooldown allows, reporting whether this caller won the claim.

func (*PostgresStore) Clear

func (s *PostgresStore) Clear(ctx context.Context) error

Clear drops the over-threshold marker, keeping last_alert_at as the record of when the last alert went out.

func (*PostgresStore) Get

func (s *PostgresStore) Get(ctx context.Context) (*Settings, error)

Get returns the stored configuration.

func (*PostgresStore) Set

func (s *PostgresStore) Set(ctx context.Context, in Settings, author string) error

Set upserts the configuration. The audit columns carry the author and the time; Settings marks its copies of them json:"-", so the section value holds the configuration alone.

type Settings

type Settings struct {
	// Enabled turns the scheduled check on. A check with no recipients or no
	// threshold delivers nothing regardless; SettingsView reports both as
	// warnings rather than refusing the save.
	Enabled bool `json:"enabled"`
	// PendingThreshold alerts when the pending count reaches it. Zero
	// disables the count condition.
	PendingThreshold int `json:"pending_threshold"`
	// OldestPendingDays alerts when the oldest pending item reaches this age
	// in days. Zero disables the age condition.
	OldestPendingDays int `json:"oldest_pending_days"`
	// CooldownHours is the minimum gap between two alerts while the queue
	// stays over threshold. A queue that drops back under and crosses again
	// alerts immediately: the cooldown suppresses repetition, not news.
	CooldownHours int `json:"cooldown_hours"`
	// Recipients are the addresses the alert is delivered to, in
	// notification.NormalizeAddress form.
	Recipients []string `json:"recipients"`
	// UpdatedBy and UpdatedAt describe the last admin write. They live in the
	// platform_settings audit columns, which are authoritative, so they are
	// excluded from the section value rather than written into it twice.
	UpdatedBy string    `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

Settings is the operator's alert configuration for one queue. It is that queue's section of the platform_settings table.

func SettingsOf

func SettingsOf(ctx context.Context, store SettingsStore, target Target) (Settings, error)

SettingsOf returns the stored configuration, or the target's defaults when none has been written. Every caller wants this rather than the raw ErrNotFound: an operator who has never opened the settings page still gets the platform's default threshold, and the recipient list is what actually gates delivery.

func (Settings) Cooldown

func (s Settings) Cooldown() time.Duration

Cooldown returns the configured re-alert gap, falling back to the default so a zero (or negative) stored value cannot turn every check into a send.

func (Settings) Crossed

func (s Settings) Crossed(pending, oldestAgeDays int) bool

Crossed reports whether a queue of pending items whose oldest is oldestAgeDays old has crossed the configured threshold. Either condition alone is enough; a zero threshold disables its condition, and an empty queue never crosses.

func (Settings) Deliverable

func (s Settings) Deliverable() bool

Deliverable reports whether a crossing could actually reach anyone: the check is on, at least one threshold can fire, and someone is listed.

func (Settings) View

func (s Settings) View() SettingsView

View maps stored settings to the read shape.

type SettingsInput

type SettingsInput struct {
	Enabled           bool     `json:"enabled" example:"true"`
	PendingThreshold  int      `json:"pending_threshold" example:"25"`
	OldestPendingDays int      `json:"oldest_pending_days" example:"30"`
	CooldownHours     int      `json:"cooldown_hours" example:"24"`
	Recipients        []string `json:"recipients" example:"data-admin@example.com"`
}

SettingsInput is the write shape for the admin alert configuration.

func (*SettingsInput) Settings

func (in *SettingsInput) Settings() Settings

Settings maps the validated input to stored settings.

func (*SettingsInput) Validate

func (in *SettingsInput) Validate() string

Validate normalizes the input in place and returns a non-empty message when it is invalid. Recipients are reduced to their storage form and de-duplicated, so the checker never mails one person twice because their address was listed in two shapes.

type SettingsStore

type SettingsStore interface {
	// Get returns the stored configuration, or ErrNotFound when the alert has
	// never been configured.
	Get(ctx context.Context) (*Settings, error)
	// Set upserts the configuration.
	Set(ctx context.Context, s Settings, author string) error
}

SettingsStore is the configuration half of one queue's alert persistence: the operator's threshold, cooldown, and recipients, held as that queue's section of the platform_settings table. (The SMTP section has its own contract over the same table rather than one widened store serving both.)

It is a contract of its own because the admin API needs nothing else: the settings surface can write the configuration without being handed the checker's claim.

type SettingsView

type SettingsView struct {
	Enabled           bool      `json:"enabled" example:"true"`
	PendingThreshold  int       `json:"pending_threshold" example:"25"`
	OldestPendingDays int       `json:"oldest_pending_days" example:"30"`
	CooldownHours     int       `json:"cooldown_hours" example:"24"`
	Recipients        []string  `json:"recipients"`
	UpdatedBy         string    `json:"updated_by,omitempty" example:"admin@example.com"`
	UpdatedAt         time.Time `json:"updated_at"`
	// Warnings describes a configuration that saves cleanly but delivers
	// nothing. They never block a save; they exist so the operator sees the
	// gap at the surface where the setting was chosen.
	Warnings []string `json:"warnings,omitempty"`
}

SettingsView is the read shape for the admin alert configuration.

type Source added in v1.121.0

type Source interface {
	// Pending returns the queue's rollup as of now: how much is waiting and how
	// old the oldest of it is.
	Pending(ctx context.Context, now time.Time) (notification.ReviewQueue, error)
}

Source reports what one review queue is holding. It is the only part of a check that differs between queues beyond the strings in the Target.

type StateStore

type StateStore interface {
	// ClaimAlert stamps a new alert at now and reports whether this caller
	// won it. It loses when an alert for the same continuously-over-threshold
	// stretch was stamped less than cooldown ago, which is what keeps a
	// stale queue from mailing on every check.
	ClaimAlert(ctx context.Context, cooldown time.Duration, now time.Time) (bool, error)
	// Clear drops the over-threshold marker. The next crossing then alerts
	// immediately instead of serving out a cooldown that belongs to a queue
	// which has since been worked.
	Clear(ctx context.Context) error
}

StateStore is the re-alert marker half of one queue's persistence: whether that queue is currently over threshold and when it was last alerted about.

The claim is the whole de-duplication mechanism. It is one conditional write, so it answers both questions a scheduled alert has to answer -- "has this already been sent recently?" and "is another replica sending it right now?" -- without a second coordination primitive.

type Target added in v1.121.0

type Target struct {
	// Queue is the stable key of this queue: the primary key of its state row
	// and the identifier in logs. It is never derived from a display string,
	// because renaming a queue in the UI must not orphan its cooldown.
	Queue string
	// SettingsSection is the platform_settings section holding the operator's
	// configuration for this queue.
	SettingsSection string
	// Category and Kind are the notification category the alert is enqueued
	// under and the payload kind its renderer dispatches on.
	Category string
	Kind     string
	// Title labels the queue in the email.
	Title string
	// Route is the portal path the alert links to, relative to the portal base
	// URL.
	Route string
	// DefaultOldestDays is the age threshold applied before an operator has
	// configured one.
	DefaultOldestDays int
}

Target names one review queue: where its configuration and its last-alert marker live, and what the email it raises says.

It is a value rather than an interface because none of it is behavior. The behavior that does differ between queues — what counts as pending — is the Source.

func KnowledgeTarget added in v1.121.0

func KnowledgeTarget() Target

KnowledgeTarget describes the knowledge insight review queue (#803).

Its settings section keeps the name it was written under: the section is a stored key, and renaming it would silently strand every deployment's configured recipients behind a section nothing reads.

func (Target) DefaultSettings added in v1.121.0

func (t Target) DefaultSettings() Settings

DefaultSettings returns the configuration applied before an operator has written one for this queue: on, alerting on the queue's own age threshold, once a day at most, to nobody yet.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL