a0774f4f1a
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
312 lines
12 KiB
Go
312 lines
12 KiB
Go
package alertsource
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/db/dialect"
|
|
)
|
|
|
|
// ErrReservationAttachedElsewhere and ErrReservationGone are the two
|
|
// sentinels AttachReservationTaskID's zero-row diagnostic can return (D3(b),
|
|
// A5). ErrReservationGone means a DEFINITE no-live-row observation: T06
|
|
// stops retrying and skips ReleaseOrphanedReservation on either sentinel,
|
|
// but keeps retrying on a plain (wrapped) store error — so a transient
|
|
// query failure must never be reported as either one.
|
|
var (
|
|
ErrReservationAttachedElsewhere = errors.New("alertsource: reservation attached to a different task")
|
|
ErrReservationGone = errors.New("alertsource: reservation released or absent")
|
|
)
|
|
|
|
// Store persists the three tables shared by every registered alert source:
|
|
// alert_sources, alert_watches and alert_reservations. Source/watch CRUD is
|
|
// T06 (D19); Store exposes only the five reservation-lifecycle operations
|
|
// D3 assigns to T04.
|
|
type Store struct {
|
|
db *sqlx.DB
|
|
ro *sqlx.DB
|
|
}
|
|
|
|
// NewStore creates a Store and initializes the schema if needed.
|
|
func NewStore(writer, reader *sqlx.DB) (*Store, error) {
|
|
s := &Store{db: writer, ro: reader}
|
|
if err := s.initSchema(); err != nil {
|
|
return nil, fmt.Errorf("alertsource schema init: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// alertSourcesColumns follows the sentry_configs/sentry_issue_watches
|
|
// identity convention exactly: TEXT PRIMARY KEY, TEXT NOT NULL references
|
|
// (D14). health_status is the D7 closed set, guarded by both a CHECK and Go
|
|
// constants; last_checked_at is deliberately absent (R10) — do not add it
|
|
// back.
|
|
const alertSourcesColumns = `
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
config_json TEXT NOT NULL DEFAULT '{}',
|
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
health_status TEXT NOT NULL DEFAULT 'unknown' CHECK (health_status IN ('unknown','ok','auth_failed','error')),
|
|
last_error TEXT NOT NULL DEFAULT '',
|
|
last_error_at DATETIME,
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NOT NULL,
|
|
UNIQUE(workspace_id, name)`
|
|
|
|
// alertWatchesColumns is the 18 columns alert_watches shares with
|
|
// sentry_issue_watches/linear's issue-watch table, plus source_id — the
|
|
// structural analogue of sentry_instance_id, but NOT NULL: unlike Sentry
|
|
// there is no legacy unbound row (D8, R9).
|
|
const alertWatchesColumns = `
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
source_id TEXT NOT NULL,
|
|
workflow_id TEXT NOT NULL,
|
|
workflow_step_id TEXT NOT NULL,
|
|
repository_id TEXT NOT NULL DEFAULT '',
|
|
base_branch TEXT NOT NULL DEFAULT '',
|
|
filter_json TEXT NOT NULL DEFAULT '{}',
|
|
agent_profile_id TEXT NOT NULL DEFAULT '',
|
|
executor_profile_id TEXT NOT NULL DEFAULT '',
|
|
prompt TEXT NOT NULL DEFAULT '',
|
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
poll_interval_seconds INTEGER NOT NULL DEFAULT 300,
|
|
max_inflight_tasks INTEGER DEFAULT 5,
|
|
last_polled_at DATETIME,
|
|
last_error TEXT NOT NULL DEFAULT '',
|
|
last_error_at DATETIME,
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NOT NULL,
|
|
FOREIGN KEY(source_id) REFERENCES alert_sources(id) ON DELETE RESTRICT`
|
|
|
|
// alertReservationsColumnsFmt is a format string: %s is the dialect-specific
|
|
// COLLATE clause for fingerprint — the one genuinely non-portable concern in
|
|
// this schema (D14), so it is the one manual dialect branch rather than a
|
|
// dialect.RenderSchema token. alert_reservations deliberately has no
|
|
// updated_at (D14): its only two mutations each already have their own
|
|
// observable column (task_id, released_at).
|
|
const alertReservationsColumnsFmt = `
|
|
id TEXT PRIMARY KEY,
|
|
watch_id TEXT NOT NULL,
|
|
fingerprint TEXT %s NOT NULL CHECK (length(fingerprint) = 64),
|
|
task_id TEXT NOT NULL DEFAULT '',
|
|
alert_raw TEXT NOT NULL DEFAULT '',
|
|
created_at DATETIME NOT NULL,
|
|
released_at DATETIME,
|
|
FOREIGN KEY(watch_id) REFERENCES alert_watches(id) ON DELETE CASCADE`
|
|
|
|
// fingerprintCollate returns fingerprint's dialect-specific COLLATE clause.
|
|
// An unqualified Postgres TEXT column inherits the database's default
|
|
// collation, which may be case-insensitive or nondeterministic; a
|
|
// case-insensitive comparison in the partial unique index would collapse
|
|
// two distinct fingerprints and silently suppress an alert (D14, following
|
|
// the tasks.external_id precedent).
|
|
func fingerprintCollate(driver string) string {
|
|
if dialect.IsPostgres(driver) {
|
|
return `COLLATE "C"`
|
|
}
|
|
return `COLLATE BINARY`
|
|
}
|
|
|
|
// createTablesSQL builds the three-table schema. The reserve path (D1) must
|
|
// carry the partial index's predicate in its ON CONFLICT target — against a
|
|
// partial index, `ON CONFLICT(watch_id, fingerprint) DO NOTHING` alone is a
|
|
// hard parse error in both dialects.
|
|
func createTablesSQL(driver string) string {
|
|
reservations := fmt.Sprintf(alertReservationsColumnsFmt, fingerprintCollate(driver))
|
|
return fmt.Sprintf(`
|
|
CREATE TABLE IF NOT EXISTS alert_sources (%s
|
|
);
|
|
CREATE TABLE IF NOT EXISTS alert_watches (%s
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_alert_watches_source ON alert_watches(source_id);
|
|
CREATE TABLE IF NOT EXISTS alert_reservations (%s
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uniq_alert_reservations_live
|
|
ON alert_reservations(watch_id, fingerprint) WHERE released_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_alert_reservations_task
|
|
ON alert_reservations(task_id) WHERE task_id != '';
|
|
`, alertSourcesColumns, alertWatchesColumns, reservations)
|
|
}
|
|
|
|
func (s *Store) initSchema() error {
|
|
rendered, err := dialect.RenderSchema(s.db.DriverName(), createTablesSQL(s.db.DriverName()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.Exec(rendered)
|
|
return err
|
|
}
|
|
|
|
func requireNonEmpty(argName, value string) error {
|
|
if value == "" {
|
|
return fmt.Errorf("alertsource: %s must not be empty", argName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireFingerprint(fingerprint string) error {
|
|
if !isFingerprint(fingerprint) {
|
|
return fmt.Errorf("alertsource: fingerprint is not a valid Fingerprint output")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReserveFingerprint claims the (watchID, fingerprint) pair for a new
|
|
// reservation. It returns the reservation ID only when the insert wins.
|
|
// It contains no SELECT (D16.1): a single INSERT whose ON CONFLICT target
|
|
// carries the partial index's predicate absorbs a concurrent duplicate
|
|
// delivery by index rejection alone. reserved=false, err=nil means another
|
|
// caller already holds a live reservation for this pair — not an error
|
|
// condition.
|
|
//
|
|
// alertRaw is stored as ” when nil or empty (the column is NOT NULL
|
|
// DEFAULT ”), never NULL. Row id and created_at are generated in Go at the
|
|
// call site, never CURRENT_TIMESTAMP, so SQLite and Postgres cannot diverge
|
|
// on format or clock.
|
|
//
|
|
// Concurrency note: if a concurrent ReleaseReservationForTask/
|
|
// ReleaseOrphanedReservation commits between this statement's conflict
|
|
// check and its own commit, a losing caller here still gets reserved=false
|
|
// — the index entry it conflicted with was live at check time — and the
|
|
// alert is re-reserved on the next delivery. That produces a task one
|
|
// delivery later, never zero tasks forever.
|
|
func (s *Store) ReserveFingerprint(ctx context.Context, watchID, fingerprint string, alertRaw []byte) (string, bool, error) {
|
|
if err := requireNonEmpty("watchID", watchID); err != nil {
|
|
return "", false, err
|
|
}
|
|
if err := requireFingerprint(fingerprint); err != nil {
|
|
return "", false, err
|
|
}
|
|
raw := ""
|
|
if len(alertRaw) > 0 {
|
|
raw = string(alertRaw)
|
|
}
|
|
reservationID := uuid.New().String()
|
|
res, err := s.db.ExecContext(ctx, s.db.Rebind(`
|
|
INSERT INTO alert_reservations (id, watch_id, fingerprint, task_id, alert_raw, created_at)
|
|
VALUES (?, ?, ?, '', ?, ?)
|
|
ON CONFLICT (watch_id, fingerprint) WHERE released_at IS NULL DO NOTHING`),
|
|
reservationID, watchID, fingerprint, raw, time.Now().UTC())
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
rows, err := res.RowsAffected()
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if rows != 1 {
|
|
return "", false, nil
|
|
}
|
|
return reservationID, true, nil
|
|
}
|
|
|
|
// AttachReservationTaskID is a compare-and-set, idempotent for its own task
|
|
// ID (D3(b)). The reservation ID identifies the exact row claimed by the
|
|
// caller, so a delayed retry cannot attach a task to a later reservation for
|
|
// the same watch and fingerprint. The guard `(task_id = ” OR task_id = ?)`
|
|
// means a retry after an ambiguous commit reports the same success as a
|
|
// fresh attach, rather than a spurious conflict.
|
|
//
|
|
// On the zero-row path only, a single diagnostic SELECT (scoped by
|
|
// `released_at IS NULL`, so it can return at most one row) classifies the
|
|
// cause: a live row owned by a different task returns
|
|
// ErrReservationAttachedElsewhere; no live row returns ErrReservationGone.
|
|
// A failure of the diagnostic SELECT itself is returned wrapped and is
|
|
// NEVER reported as ErrReservationGone (A5) — a transient query failure
|
|
// must stay retryable, while ErrReservationGone tells the caller to stop
|
|
// retrying and release the reservation.
|
|
func (s *Store) AttachReservationTaskID(ctx context.Context, reservationID, taskID string) error {
|
|
if err := requireNonEmpty("reservationID", reservationID); err != nil {
|
|
return err
|
|
}
|
|
if err := requireNonEmpty("taskID", taskID); err != nil {
|
|
return err
|
|
}
|
|
res, err := s.db.ExecContext(ctx, s.db.Rebind(`
|
|
UPDATE alert_reservations SET task_id = ?
|
|
WHERE id = ? AND released_at IS NULL AND (task_id = '' OR task_id = ?)`),
|
|
taskID, reservationID, taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rows == 1 {
|
|
return nil
|
|
}
|
|
return s.classifyAttachFailure(ctx, reservationID)
|
|
}
|
|
|
|
func (s *Store) classifyAttachFailure(ctx context.Context, reservationID string) error {
|
|
var attachedTaskID string
|
|
err := s.ro.QueryRowContext(ctx, s.ro.Rebind(`
|
|
SELECT task_id FROM alert_reservations WHERE id = ? AND released_at IS NULL`),
|
|
reservationID).Scan(&attachedTaskID)
|
|
switch {
|
|
case err == nil:
|
|
return ErrReservationAttachedElsewhere
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return ErrReservationGone
|
|
default:
|
|
return fmt.Errorf("alertsource: classify attach failure: %w", err)
|
|
}
|
|
}
|
|
|
|
// DeleteReservation removes a live reservation outright. It runs after task
|
|
// creation has already failed, before any task ID exists to attach, so a
|
|
// zero-row result is a no-op rather than an error. The reservation ID makes
|
|
// this compensating action safe when a later delivery has already reserved
|
|
// the same fingerprint again.
|
|
func (s *Store) DeleteReservation(ctx context.Context, reservationID string) error {
|
|
if err := requireNonEmpty("reservationID", reservationID); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.db.ExecContext(ctx, s.db.Rebind(
|
|
`DELETE FROM alert_reservations WHERE id = ? AND released_at IS NULL AND task_id = ''`),
|
|
reservationID)
|
|
return err
|
|
}
|
|
|
|
// ReleaseReservationForTask stamps released_at for the reservation attached
|
|
// to taskID, called on task close/archive (T06 subscribes). An empty
|
|
// taskID is rejected before the statement runs: task_id is NOT NULL
|
|
// DEFAULT ”, so an empty argument would otherwise match and release every
|
|
// unattached row in the table. Most tasks carry no reservation, so a
|
|
// zero-row result is a no-op, not an error; `released_at IS NULL` also
|
|
// makes this idempotent against a redelivered close event.
|
|
func (s *Store) ReleaseReservationForTask(ctx context.Context, taskID string) error {
|
|
if err := requireNonEmpty("taskID", taskID); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.db.ExecContext(ctx, s.db.Rebind(
|
|
`UPDATE alert_reservations SET released_at = ? WHERE task_id = ? AND released_at IS NULL`),
|
|
time.Now().UTC(), taskID)
|
|
return err
|
|
}
|
|
|
|
// ReleaseOrphanedReservation stamps released_at for a reservation whose
|
|
// attach permanently failed after a successful task create. The reservation
|
|
// ID prevents a delayed cleanup from releasing a later reservation for the
|
|
// same watch and fingerprint.
|
|
func (s *Store) ReleaseOrphanedReservation(ctx context.Context, reservationID string) error {
|
|
if err := requireNonEmpty("reservationID", reservationID); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.db.ExecContext(ctx, s.db.Rebind(`
|
|
UPDATE alert_reservations SET released_at = ?
|
|
WHERE id = ? AND released_at IS NULL AND task_id = ''`),
|
|
time.Now().UTC(), reservationID)
|
|
return err
|
|
}
|