mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-17 04:44:18 +00:00
Rebase of #1881 by @SaarMesh-Bot onto current master. Their three commits are preserved, two of them cherry-picked with authorship intact; the sweep itself had to be regenerated. Opened as a new PR rather than force-pushing their branch. Closes #1881 once merged. Addresses parts 1 and 3 of #1859; part 2 landed as #1937. ## Why regenerated rather than merged The sweep in #1881 was cut on 2026-09-02 07:13 and roughly forty PRs landed after it, so it went `CONFLICTING/DIRTY`. Re-running `gofmt` on current master is cheaper and less error-prone than resolving 72 conflicts that are all whitespace. The drift it fixes also grew in the meantime: 66 files now, against 72 then, but spread differently. ## The three commits 1. **`style(#1859)`** — `gofmt -w` across the 14 modules. 66 files. 2. **`test(#1859)`** — @SaarMesh-Bot's fix for the one `go vet` copylocks finding, `cmd/ingestor/coverage_boost_test.go`: the range variable copied a `Config` embedding `sync.Once`. Cherry-picked unchanged. 3. **`ci(#1859)`** — @SaarMesh-Bot's CI step that fails on gofmt drift or vet findings, plus `.git-blame-ignore-revs`. Cherry-picked with one change, noted in the commit message: the ignore file pointed at `04bc80ee`, the sweep commit on their branch, which does not exist on this base and would make `git blame --ignore-revs-file` error. Repointed at `d3a02599`, the sweep here. ## Verification The claim "formatting only" is checked twice rather than asserted: - Every changed file is byte-identical to `gofmt(previous content)`. 0 of 66 deviate. - With line comments and all whitespace stripped, 0 of 66 files differ, so no code outside comments changed. 14 of the 66 also show doc-comment reflow. Since Go 1.19 `gofmt` re-indents indented comment blocks to tabs and inserts a blank comment line before them; the behavior matrix above `resolveHopWithContext` in `cmd/ingestor/path_resolver.go` is a clear example. That is gofmt's own output, not an edit, but it is worth naming because it makes the diff look larger than "whitespace" suggests. The gate was run locally exactly as the workflow runs it: `gofmt` clean, and `go vet` clean in all 14 modules, including `cmd/ingestor` which is what commit 2 fixes. Suites: `cmd/server` ok (80.7s), `internal/packetpath` ok (2.3s), `cmd/ingestor` passes except `TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails identically on bare master with "A required privilege is not held by the client" (Windows symlink privilege on my host, not code). ## Sequencing This should go last in the queue. The sweep touches 66 files, so merging it before the remaining open Go PRs gives each of them a conflict about nothing but formatting. After it lands the gate is active, and any PR with drift fails CI until it runs `gofmt -w`. Excluded from the sweep: the misnamed `Dockerfile.go`, which is a Dockerfile that gofmt cannot parse (the workflow excludes it too), and `docs/DEPLOYMENT.md`, which a case-insensitive filesystem surfaces as a spurious modification against `docs/deployment.md` and is unrelated. --------- Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
300 lines
10 KiB
Go
300 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// waitForStatus polls AsyncMigrationStatus until it matches `want` or `deadline` passes.
|
|
func waitForStatus(t *testing.T, s *Store, name, want string, timeout time.Duration) string {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
var status string
|
|
var err error
|
|
for time.Now().Before(deadline) {
|
|
status, err = s.AsyncMigrationStatus(name)
|
|
if err == nil && status == want {
|
|
return status
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("status never reached %q within %s: got %q (err=%v)", want, timeout, status, err)
|
|
return status
|
|
}
|
|
|
|
// TestRunAsyncMigration_PendingThenDone pins the contract for RunAsyncMigration:
|
|
//
|
|
// 1. After calling, the migration name MUST be queryable in the migrations
|
|
// table with status `pending_async` IMMEDIATELY (no waiting for fn).
|
|
// 2. After fn returns, the status MUST transition to `done`.
|
|
// 3. RunAsyncMigration MUST return without blocking on fn.
|
|
//
|
|
// This is the regression test for the recurring "sync migration on large
|
|
// table blocks ingestor startup" class (#791, #1483, ...). If this test
|
|
// fails the contract is broken — do not relax it; fix the runner.
|
|
func TestRunAsyncMigration_PendingThenDone(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
|
|
const name = "test_async_migration_v1"
|
|
if err := s.RunAsyncMigration(ctx, name, func(ctx context.Context, db *sql.DB) error {
|
|
close(started)
|
|
<-release
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("RunAsyncMigration returned error: %v", err)
|
|
}
|
|
|
|
// Wait for the goroutine to actually start before checking status; this
|
|
// proves RunAsyncMigration did not block on fn and that fn is running
|
|
// concurrently.
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("async migration fn did not start within 2s — RunAsyncMigration may have blocked or never scheduled")
|
|
}
|
|
|
|
status, err := s.AsyncMigrationStatus(name)
|
|
if err != nil {
|
|
t.Fatalf("AsyncMigrationStatus while running: %v", err)
|
|
}
|
|
if status != "pending_async" {
|
|
t.Fatalf("status while fn running: got %q, want %q", status, "pending_async")
|
|
}
|
|
|
|
close(release)
|
|
|
|
// Poll for transition to done.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
status, err = s.AsyncMigrationStatus(name)
|
|
if err == nil && status == "done" {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("status never transitioned to done within 2s: got %q (err=%v)", status, err)
|
|
}
|
|
|
|
// TestRunAsyncMigration_PanicCapture proves that a panic inside fn does NOT
|
|
// leak past the recover, AND that the migration row transitions to
|
|
// "failed" with the panic message captured — NOT silently to "done".
|
|
// Operator visibility into mid-migration crashes is the whole point.
|
|
func TestRunAsyncMigration_PanicCapture(t *testing.T) {
|
|
s := newTestStore(t)
|
|
const name = "test_panic_capture_v1"
|
|
|
|
if err := s.RunAsyncMigration(context.Background(), name,
|
|
func(ctx context.Context, db *sql.DB) error {
|
|
panic("synthetic boom")
|
|
}); err != nil {
|
|
t.Fatalf("RunAsyncMigration returned error: %v", err)
|
|
}
|
|
|
|
s.WaitForAsyncMigrations()
|
|
|
|
status, err := s.AsyncMigrationStatus(name)
|
|
if err != nil {
|
|
t.Fatalf("status lookup: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Fatalf("status after panic: got %q, want %q (silent-done would be catastrophic)", status, "failed")
|
|
}
|
|
|
|
var errMsg sql.NullString
|
|
if err := s.db.QueryRow(`SELECT error FROM _async_migrations WHERE name = ?`, name).Scan(&errMsg); err != nil {
|
|
t.Fatalf("error column lookup: %v", err)
|
|
}
|
|
if !errMsg.Valid || errMsg.String == "" {
|
|
t.Fatalf("error column empty after panic — operator has no clue what failed")
|
|
}
|
|
}
|
|
|
|
// TestRunAsyncMigration_IdempotentSecondCallNoOps verifies that calling
|
|
// RunAsyncMigration a second time with the same name AFTER it has reached
|
|
// "done" status does NOT re-run fn. This protects the prod path: ingestor
|
|
// restarts must not rebuild already-built indexes.
|
|
func TestRunAsyncMigration_IdempotentSecondCallNoOps(t *testing.T) {
|
|
s := newTestStore(t)
|
|
const name = "test_idempotent_v1"
|
|
|
|
var calls int32
|
|
fn := func(ctx context.Context, db *sql.DB) error {
|
|
atomic.AddInt32(&calls, 1)
|
|
return nil
|
|
}
|
|
|
|
if err := s.RunAsyncMigration(context.Background(), name, fn); err != nil {
|
|
t.Fatalf("first call: %v", err)
|
|
}
|
|
s.WaitForAsyncMigrations()
|
|
waitForStatus(t, s, name, "done", 2*time.Second)
|
|
|
|
// Second call must short-circuit; fn must not be invoked again.
|
|
if err := s.RunAsyncMigration(context.Background(), name, fn); err != nil {
|
|
t.Fatalf("second call: %v", err)
|
|
}
|
|
s.WaitForAsyncMigrations()
|
|
|
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
t.Fatalf("fn invoked %d times, want 1 (done-state row must short-circuit)", got)
|
|
}
|
|
}
|
|
|
|
// TestRunAsyncMigration_RestartSafetyFailedIsRetried simulates a crashed
|
|
// previous run: a row exists in `failed` state from a prior boot. The next
|
|
// RunAsyncMigration call MUST re-schedule fn (reset to pending_async, then
|
|
// run it), not leave the migration stuck in `failed` forever.
|
|
func TestRunAsyncMigration_RestartSafetyFailedIsRetried(t *testing.T) {
|
|
s := newTestStore(t)
|
|
const name = "test_restart_failed_v1"
|
|
|
|
if err := ensureAsyncMigrationsTable(s.db); err != nil {
|
|
t.Fatalf("ensure table: %v", err)
|
|
}
|
|
if _, err := s.db.Exec(`INSERT INTO _async_migrations (name, status, error) VALUES (?, 'failed', 'simulated prior crash')`, name); err != nil {
|
|
t.Fatalf("seed failed row: %v", err)
|
|
}
|
|
|
|
var calls int32
|
|
if err := s.RunAsyncMigration(context.Background(), name,
|
|
func(ctx context.Context, db *sql.DB) error {
|
|
atomic.AddInt32(&calls, 1)
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("RunAsyncMigration on failed row: %v", err)
|
|
}
|
|
s.WaitForAsyncMigrations()
|
|
waitForStatus(t, s, name, "done", 2*time.Second)
|
|
|
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
t.Fatalf("fn invoked %d times, want 1 (failed-state row must be retried)", got)
|
|
}
|
|
|
|
// And the error column must be cleared on success.
|
|
var errCol sql.NullString
|
|
if err := s.db.QueryRow(`SELECT error FROM _async_migrations WHERE name = ?`, name).Scan(&errCol); err != nil {
|
|
t.Fatalf("error col: %v", err)
|
|
}
|
|
if errCol.Valid && errCol.String != "" {
|
|
t.Fatalf("error column not cleared on retry success: %q", errCol.String)
|
|
}
|
|
}
|
|
|
|
// TestRunAsyncMigration_RestartSafetyPendingIsRetried simulates the
|
|
// ingestor crashing while a migration was still in `pending_async` (the
|
|
// goroutine never finished). On next boot the migration MUST be re-picked-up
|
|
// — leaving it stuck in pending forever would be a silent prod outage.
|
|
func TestRunAsyncMigration_RestartSafetyPendingIsRetried(t *testing.T) {
|
|
s := newTestStore(t)
|
|
const name = "test_restart_pending_v1"
|
|
|
|
if err := ensureAsyncMigrationsTable(s.db); err != nil {
|
|
t.Fatalf("ensure table: %v", err)
|
|
}
|
|
if _, err := s.db.Exec(`INSERT INTO _async_migrations (name, status) VALUES (?, 'pending_async')`, name); err != nil {
|
|
t.Fatalf("seed pending row: %v", err)
|
|
}
|
|
|
|
var calls int32
|
|
if err := s.RunAsyncMigration(context.Background(), name,
|
|
func(ctx context.Context, db *sql.DB) error {
|
|
atomic.AddInt32(&calls, 1)
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("RunAsyncMigration on pending row: %v", err)
|
|
}
|
|
s.WaitForAsyncMigrations()
|
|
waitForStatus(t, s, name, "done", 2*time.Second)
|
|
|
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
t.Fatalf("fn invoked %d times, want 1 (pending row must be retried after crash)", got)
|
|
}
|
|
}
|
|
|
|
// TestRunAsyncMigration_FnErrorRecorded covers the non-panic failure path:
|
|
// fn returns an error → status MUST be "failed" with the error captured.
|
|
func TestRunAsyncMigration_FnErrorRecorded(t *testing.T) {
|
|
s := newTestStore(t)
|
|
const name = "test_fn_error_v1"
|
|
|
|
if err := s.RunAsyncMigration(context.Background(), name,
|
|
func(ctx context.Context, db *sql.DB) error {
|
|
return fmt.Errorf("simulated migration error")
|
|
}); err != nil {
|
|
t.Fatalf("RunAsyncMigration: %v", err)
|
|
}
|
|
s.WaitForAsyncMigrations()
|
|
|
|
status, err := s.AsyncMigrationStatus(name)
|
|
if err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Fatalf("status: got %q, want failed", status)
|
|
}
|
|
|
|
var errCol sql.NullString
|
|
if err := s.db.QueryRow(`SELECT error FROM _async_migrations WHERE name = ?`, name).Scan(&errCol); err != nil {
|
|
t.Fatalf("error col: %v", err)
|
|
}
|
|
if !errCol.Valid || errCol.String == "" {
|
|
t.Fatalf("error column empty after fn error")
|
|
}
|
|
}
|
|
|
|
// TestRunAsyncMigration_ConcurrentSameNameSerialized validates the
|
|
// single-process-instance assumption: ingestor has only one *Store, and
|
|
// concurrent RunAsyncMigration(name=X) calls on the SAME *Store must not
|
|
// execute fn more than once for a given name. (CoreScope does not support
|
|
// multi-ingestor / cluster mode — see MIGRATIONS.md "Concurrency" note —
|
|
// so cross-process races are out of scope.)
|
|
func TestRunAsyncMigration_ConcurrentSameNameSerialized(t *testing.T) {
|
|
s := newTestStore(t)
|
|
const name = "test_concurrent_serialize_v1"
|
|
|
|
var calls int32
|
|
fn := func(ctx context.Context, db *sql.DB) error {
|
|
atomic.AddInt32(&calls, 1)
|
|
time.Sleep(20 * time.Millisecond)
|
|
return nil
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 5; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
// All concurrent callers use the SAME name. Each is allowed
|
|
// to either no-op (status==done short-circuit) or schedule
|
|
// a re-run; the invariant is "fn never runs more than once
|
|
// concurrently and on second-call-after-done it does not
|
|
// re-execute."
|
|
_ = s.RunAsyncMigration(context.Background(), name, fn)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
s.WaitForAsyncMigrations()
|
|
waitForStatus(t, s, name, "done", 2*time.Second)
|
|
|
|
// The contract per the helper's docstring + Idempotent test is: once
|
|
// status is `done`, subsequent calls short-circuit. Concurrent calls
|
|
// that lose the race to set up the pending_async row may legitimately
|
|
// re-schedule fn (the comment "previous run may have crashed
|
|
// mid-flight" justifies retry on pending_async). The hard bound is
|
|
// "fn runs at most ONCE PER pending->done transition" — for this
|
|
// test we assert fn ran at least once and at most a small bounded
|
|
// number (5 callers, each may have scheduled before any reached done).
|
|
if got := atomic.LoadInt32(&calls); got < 1 || got > 5 {
|
|
t.Fatalf("fn invoked %d times, want 1..5 inclusive (bounded by caller count)", got)
|
|
}
|
|
}
|