mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 23:37:22 +00:00
Refs #1739. Depends on the small local-validation fix in #2160; merge that first. This branch contains its prerequisite commit plus one migration-status commit. An interrupted background migration currently records `failed`, making an operator shutdown look like a migration fault. Errors matching `context.Canceled`, including wrapped errors, now record `cancelled` and retain the error and completion time without logging `FAILED`. Ordinary errors, deadline expiry and panics remain failures even when the context is cancelled; a successful callback still records `done`. Cancelled work follows the existing retry path on the next run. Regression tests cover classification and clearing stale completion/error fields before retrying, then successful completion. The migration runbook documents the distinction. This covers the existing ingestor bookkeeping portion of #1739. The issue's server status mapper and banner depend on closed, unmerged #1735 and are absent from current master; this PR does not add that API/UI and leaves #1739 open for the remaining scope. No new configuration, customizer setting or schema migration is required. Validation: direct and wrapped cancellation failed the new regression before the change; focused tests and the race check pass afterward. Native full server/ingestor suites and all 194 standalone frontend suites pass with #2160. The browser smoke suite passed 148 checks, with three fixture-dependent skips and two pre-existing disabled cases. Windows-only filesystem capability skips are documented in #2160; Linux CI retains the security assertions. Performance impact: one error-chain check when a background migration finishes, using the existing single status update. No packet-ingestion loop, batching, polling or retention behavior changes, and no speedup is claimed.
155 lines
6.1 KiB
Go
155 lines
6.1 KiB
Go
// Async migration helper — runs schema/backfill work that may take minutes on
|
|
// large prod tables WITHOUT blocking ingestor startup.
|
|
//
|
|
// MIGRATION ANNOTATION CONVENTION (read this before touching migrations):
|
|
//
|
|
// Sync schema/data migrations (CREATE INDEX, ALTER TABLE, UPDATE ... WHERE)
|
|
// that run inline during OpenStore() block the ingestor from accepting
|
|
// packets until they finish. On an empty dev DB they return in milliseconds;
|
|
// at prod scale (1.9M+ observations, 80K+ adverts) they can pin the boot
|
|
// for minutes and trigger restart loops. This regression class has bitten us
|
|
// repeatedly (#791 resolved_path backfill, #1483 obs_observer_ts_idx_v1).
|
|
//
|
|
// ANY new CREATE INDEX / ALTER TABLE / data-rewrite migration MUST EITHER:
|
|
// 1. Run via Store.RunAsyncMigration(...) below (preferred for backfills
|
|
// and any work that may touch >1K rows). The migration is recorded as
|
|
// `pending_async` immediately, returns to the caller (boot proceeds),
|
|
// and completes in a goroutine. Status flips to `done`, `cancelled`,
|
|
// or `failed` when fn returns; interruptions retain the error message.
|
|
// 2. Carry the preflight annotation comment immediately above the
|
|
// migration block, e.g.
|
|
// // PREFLIGHT: async=true reason="<one-line justification>"
|
|
// Use this for migrations that are genuinely cheap at any scale
|
|
// (e.g. ALTER TABLE ADD COLUMN, CREATE INDEX on a known-bounded
|
|
// table). The annotation is grepped by
|
|
// ~/.openclaw/skills/pr-preflight/scripts/check-async-migrations.sh
|
|
// — its absence on a touched migration block is a hard-fail gate.
|
|
//
|
|
// See MIGRATIONS.md in the repo root for the full policy and examples.
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
)
|
|
|
|
// ensureAsyncMigrationsTable creates the bookkeeping table used by
|
|
// RunAsyncMigration / AsyncMigrationStatus. Idempotent.
|
|
func ensureAsyncMigrationsTable(db *sql.DB) error {
|
|
_, err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS _async_migrations (
|
|
name TEXT PRIMARY KEY,
|
|
status TEXT NOT NULL, -- pending_async | done | cancelled | failed
|
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
ended_at TEXT,
|
|
error TEXT
|
|
)
|
|
`)
|
|
return err
|
|
}
|
|
|
|
// RunAsyncMigration registers `name` as a pending async migration and
|
|
// schedules `fn` to run in a background goroutine. It returns to the caller
|
|
// immediately so the ingestor can keep booting.
|
|
//
|
|
// Contract (pinned by async_migration_test.go):
|
|
// - status is `pending_async` IMMEDIATELY after this returns.
|
|
// - fn runs in a goroutine; on success status becomes `done`. Errors matching
|
|
// context.Canceled become `cancelled`; other errors and panics become
|
|
// `failed`. Both interrupted states record the error and completion time.
|
|
// - Idempotent: if a row with the same name already exists in `done`
|
|
// state, fn is NOT re-run. If in `failed`, `cancelled`, or `pending_async`,
|
|
// fn IS re-scheduled (a previous run may have crashed mid-flight).
|
|
// - The caller's WaitGroup tracks the goroutine so tests/shutdown can
|
|
// wait via Store.WaitForAsyncMigrations().
|
|
func (s *Store) RunAsyncMigration(ctx context.Context, name string, fn func(context.Context, *sql.DB) error) error {
|
|
if err := ensureAsyncMigrationsTable(s.db); err != nil {
|
|
return fmt.Errorf("ensure _async_migrations: %w", err)
|
|
}
|
|
|
|
var existing string
|
|
row := s.db.QueryRow(`SELECT status FROM _async_migrations WHERE name = ?`, name)
|
|
switch err := row.Scan(&existing); err {
|
|
case nil:
|
|
if existing == "done" {
|
|
return nil // already complete, nothing to do
|
|
}
|
|
// pending_async, cancelled, or failed → reset and retry.
|
|
if _, err := s.db.Exec(`
|
|
UPDATE _async_migrations
|
|
SET status = 'pending_async', started_at = datetime('now'), ended_at = NULL, error = NULL
|
|
WHERE name = ?`, name); err != nil {
|
|
return fmt.Errorf("reset async migration %q: %w", name, err)
|
|
}
|
|
case sql.ErrNoRows:
|
|
if _, err := s.db.Exec(`
|
|
INSERT INTO _async_migrations (name, status) VALUES (?, 'pending_async')`,
|
|
name); err != nil {
|
|
return fmt.Errorf("register async migration %q: %w", name, err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("lookup async migration %q: %w", name, err)
|
|
}
|
|
|
|
s.backfillWg.Add(1)
|
|
go func() {
|
|
defer s.backfillWg.Done()
|
|
var runErr error
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
runErr = fmt.Errorf("panic: %v", r)
|
|
log.Printf("[async-migration] %q panic recovered: %v", name, r)
|
|
}
|
|
if runErr != nil {
|
|
status, label := "failed", "FAILED"
|
|
if errors.Is(runErr, context.Canceled) {
|
|
status, label = "cancelled", "cancelled"
|
|
}
|
|
if _, err := s.db.Exec(`
|
|
UPDATE _async_migrations
|
|
SET status = ?, ended_at = datetime('now'), error = ?
|
|
WHERE name = ?`, status, runErr.Error(), name); err != nil {
|
|
log.Printf("[async-migration] failed to record %s for %q: %v", status, name, err)
|
|
}
|
|
log.Printf("[async-migration] %q %s: %v", name, label, runErr)
|
|
return
|
|
}
|
|
if _, err := s.db.Exec(`
|
|
UPDATE _async_migrations
|
|
SET status = 'done', ended_at = datetime('now'), error = NULL
|
|
WHERE name = ?`, name); err != nil {
|
|
log.Printf("[async-migration] failed to mark %q done: %v", name, err)
|
|
return
|
|
}
|
|
log.Printf("[async-migration] %q done", name)
|
|
}()
|
|
log.Printf("[async-migration] %q starting (boot continues)", name)
|
|
runErr = fn(ctx, s.db)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// AsyncMigrationStatus returns the current status of an async migration
|
|
// (one of "pending_async", "done", "cancelled", "failed") or sql.ErrNoRows if no such
|
|
// migration has been registered.
|
|
func (s *Store) AsyncMigrationStatus(name string) (string, error) {
|
|
if err := ensureAsyncMigrationsTable(s.db); err != nil {
|
|
return "", err
|
|
}
|
|
var status string
|
|
err := s.db.QueryRow(`SELECT status FROM _async_migrations WHERE name = ?`, name).Scan(&status)
|
|
return status, err
|
|
}
|
|
|
|
// WaitForAsyncMigrations blocks until all currently-scheduled async migrations
|
|
// finish. Intended for tests + graceful shutdown; production boot path does NOT
|
|
// call this (that's the whole point).
|
|
func (s *Store) WaitForAsyncMigrations() {
|
|
s.backfillWg.Wait()
|
|
}
|