fix(tasks): serialize no-result decisions with finding writes

This commit is contained in:
ji ruo committed 2026-11-11 15:20:58 +08:00
1 parent 8e030b1fbd
commit 3306e547b2
11 files changed
+369 -37

No files matched your search

+4 -3
View File
@@ -312,6 +312,7 @@ type FindingGroup struct {
TaskName string `json:"task_name"` // 可选任务名称;空=未命名
TaskDescription string `json:"task_description"`
TaskStatus string `json:"task_status"`
TaskStopReason string `json:"task_stop_reason"`
Count int `json:"count"`
Critical int `json:"critical"`
High int `json:"high"`
@@ -336,7 +337,7 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
}
where, args := f.where()
grouped := ` FROM findings f LEFT JOIN tasks t ON f.task_id=t.id` + where +
` GROUP BY t.id, t.name, t.description, t.status, t.paused, t.queued`
` GROUP BY t.id, t.name, t.description, t.status, t.paused, t.queued, t.stop_reason`
var groupTotal, findingTotal int
countQuery := `SELECT COUNT(*), COALESCE(SUM(finding_count),0) FROM (` +
@@ -359,7 +360,7 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
WHEN t.queued THEN 'queued'
WHEN t.paused THEN 'paused'
ELSE t.status
END, ''),
END, ''), COALESCE(t.stop_reason,''),
COUNT(*),
COUNT(*) FILTER (WHERE f.severity='critical'),
COUNT(*) FILTER (WHERE f.severity='high'),
@@ -376,7 +377,7 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
for rows.Next() {
var group FindingGroup
var taskID sql.NullInt64
if err := rows.Scan(&taskID, &group.TaskName, &group.TaskDescription, &group.TaskStatus, &group.Count,
if err := rows.Scan(&taskID, &group.TaskName, &group.TaskDescription, &group.TaskStatus, &group.TaskStopReason, &group.Count,
&group.Critical, &group.High, &group.Medium, &group.Low, &group.LastFoundAt); err != nil {
return nil, 0, 0, err
}
+52 -23
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"fmt"
"time"
@@ -26,15 +27,7 @@ func NormalizeNoResultOptions(opts *TaskCreateOptions) error {
}
}
// CountQualifyingFindings counts task-local independent findings, using the case
// severity for grouped observations. False positives never count; unassessed
// findings count only when the caller sets no severity threshold.
func (d *DB) CountQualifyingFindings(taskID int64, minSeverity string) (int, error) {
if err := NormalizeNoResultOptions(&TaskCreateOptions{NoResultMinSeverity: minSeverity}); err != nil {
return 0, err
}
var count int
err := d.QueryRow(`WITH observations AS (
const countQualifyingFindingsSQL = `WITH observations AS (
SELECT CASE WHEN m.case_id IS NULL THEN 'f:'||f.id ELSE 'c:'||m.case_id END entity,
CASE WHEN m.case_id IS NULL THEN f.severity ELSE c.severity END severity
FROM findings f
@@ -44,25 +37,61 @@ func (d *DB) CountQualifyingFindings(taskID int64, minSeverity string) (int, err
)
SELECT count(DISTINCT entity) FROM observations
WHERE $2='' OR array_position(ARRAY['low','medium','high','critical'],severity)
>= array_position(ARRAY['low','medium','high','critical'],$2)`, taskID, minSeverity).Scan(&count)
>= array_position(ARRAY['low','medium','high','critical'],$2)`
// CountQualifyingFindings counts task-local independent findings, using the case
// severity for grouped observations. False positives never count; unassessed
// findings count only when the caller sets no severity threshold.
func (d *DB) CountQualifyingFindings(taskID int64, minSeverity string) (int, error) {
if err := NormalizeNoResultOptions(&TaskCreateOptions{NoResultMinSeverity: minSeverity}); err != nil {
return 0, err
}
var count int
err := d.QueryRow(countQualifyingFindingsSQL, taskID, minSeverity).Scan(&count)
return count, err
}
// MarkNoResultChecked durably claims the single checkpoint. A terminal task or
// an already checked checkpoint returns nil, so concurrent callers cannot stop
// a task twice. A failed decision also persists its reason so restart can finish
// settling without recounting findings that arrived during the drain.
func (d *DB) MarkNoResultChecked(taskID int64, stop bool) (*time.Time, error) {
var checked time.Time
err := d.QueryRow(`UPDATE tasks SET no_result_checked_at=now(), stop_reason=CASE WHEN $2 THEN 'no_result' ELSE '' END
WHERE id=$1 AND deleted_at IS NULL AND no_result_checked_at IS NULL
AND status NOT IN ('done','failed','timeout')
RETURNING no_result_checked_at`, taskID, stop).Scan(&checked)
// CheckNoResultCheckpoint serializes the count and decision with finding writes
// using their task row lock. A nil checked timestamp means the checkpoint was
// not claimed. Findings committed before the lock is acquired count; later
// writes cannot reverse a persisted stop, even during restart/settling.
func (d *DB) CheckNoResultCheckpoint(ctx context.Context, taskID int64, minFindings int, minSeverity string) (count int, stop bool, checked *time.Time, err error) {
if err = NormalizeNoResultOptions(&TaskCreateOptions{NoResultMinSeverity: minSeverity}); err != nil {
return
}
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return 0, false, nil, err
}
defer tx.Rollback()
var status string
var previous sql.NullTime
err = tx.QueryRowContext(ctx, `SELECT status, no_result_checked_at FROM tasks
WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, taskID).Scan(&status, &previous)
if err == sql.ErrNoRows {
return nil, nil
return 0, false, nil, nil
}
if err != nil {
return nil, err
return 0, false, nil, err
}
return &checked, nil
if previous.Valid || status == "done" || status == "failed" || status == "timeout" {
return 0, false, nil, nil
}
// READ COMMITTED takes a fresh snapshot for this separate statement, after
// any finding transaction that held the task lock has committed. Combining
// this with the locking SELECT would retain the snapshot from before waiting.
if err = tx.QueryRowContext(ctx, countQualifyingFindingsSQL, taskID, minSeverity).Scan(&count); err != nil {
return 0, false, nil, err
}
stop = count < max(1, minFindings)
var at time.Time
if err = tx.QueryRowContext(ctx, `UPDATE tasks SET no_result_checked_at=clock_timestamp(),
stop_reason=CASE WHEN $2 THEN 'no_result' ELSE '' END WHERE id=$1
RETURNING no_result_checked_at`, taskID, stop).Scan(&at); err != nil {
return 0, false, nil, err
}
if err = tx.Commit(); err != nil {
return 0, false, nil, err
}
return count, stop, &at, nil
}
+213 -2
View File
@@ -1,8 +1,11 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
)
func TestNormalizeNoResultOptions(t *testing.T) {
@@ -79,10 +82,10 @@ func TestNoResultPersistenceGuardAndLegacyArchive(t *testing.T) {
if stored, err := d.GetTask(task.ID); err != nil || stored.NoResultSeconds != 60 || stored.NoResultMinFindings != 1 || stored.NoResultMinSeverity != "high" {
t.Fatalf("creation round trip: %+v, %v", stored, err)
}
if checked, err := d.MarkNoResultChecked(task.ID, true); err != nil || checked == nil {
if _, _, checked, err := d.CheckNoResultCheckpoint(t.Context(), task.ID, 1, "high"); err != nil || checked == nil {
t.Fatalf("first check: %v, %v", checked, err)
}
if checked, err := d.MarkNoResultChecked(task.ID, true); err != nil || checked != nil {
if _, _, checked, err := d.CheckNoResultCheckpoint(t.Context(), task.ID, 1, "high"); err != nil || checked != nil {
t.Fatalf("second check: %v, %v", checked, err)
}
if _, _, won, err := d.SetTerminalStatusGuardedReasonRuntime(task.ID, "timeout", StopReasonNoResult); err != nil || !won {
@@ -132,3 +135,211 @@ func TestNoResultPersistenceGuardAndLegacyArchive(t *testing.T) {
t.Fatalf("legacy defaults: %d %d %q %q, %v", seconds, minimum, severity, reason, err)
}
}
// Register close first so all test data (including non-cascading notification
// events) is removed before the database connection is closed.
func noResultTestDB(t *testing.T) (*DB, *Task) {
t.Helper()
d, err := Open(testDSN(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.Close() })
task, err := d.CreateTaskWithOptions("checkpoint concurrency", "goal", TaskCreateOptions{NoResultSeconds: 60})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
for _, query := range []string{
`DELETE FROM notification_events WHERE finding_id IN (SELECT id FROM findings WHERE task_id=$1)`,
`DELETE FROM findings WHERE task_id=$1`,
} {
if _, err := d.Exec(query, task.ID); err != nil {
t.Error(err)
}
}
if err := d.DeleteTask(task.ID); err != nil {
t.Error(err)
}
})
return d, task
}
// Observe an actual PostgreSQL lock wait before starting the 500ms assertion,
// rather than assuming the goroutine has been scheduled by then.
func waitForNoResultLockWait(t *testing.T, d *DB, blockerPID int) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
tick := time.NewTicker(10 * time.Millisecond)
defer tick.Stop()
for {
var blocked bool
if err := d.QueryRowContext(ctx, `SELECT EXISTS (
SELECT 1 FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid))
AND query LIKE '%tasks%')`, blockerPID).Scan(&blocked); err != nil {
t.Fatal(err)
}
if blocked {
return
}
select {
case <-ctx.Done():
t.Fatal("operation never waited for the task row lock")
case <-tick.C:
}
}
}
func TestNoResultCheckpointWaitsForFindingCommit(t *testing.T) {
d, task := noResultTestDB(t)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
ready := make(chan int, 1)
release := make(chan struct{})
writerDone := make(chan struct{})
writerErr := make(chan error, 1)
go func() {
defer close(writerDone)
writerErr <- d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
if _, err := RecordFindingTx(ctx, tx, RecordFindingInput{
TaskID: task.ID, ExplorationID: task.ExplorationID, Severity: "high", Worker: "test",
}, nil); err != nil {
return err
}
var pid int
if err := tx.QueryRow(`SELECT pg_backend_pid()`).Scan(&pid); err != nil {
return err
}
ready <- pid
select {
case <-release:
return nil
case <-ctx.Done():
return ctx.Err()
}
})
}()
defer func() { cancel(); <-writerDone }()
var pid int
select {
case pid = <-ready:
case err := <-writerErr:
t.Fatalf("record finding: %v", err)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
type decision struct {
count int
stop bool
checked *time.Time
err error
}
result := make(chan decision, 1)
checkerDone := make(chan struct{})
go func() {
defer close(checkerDone)
count, stop, checked, err := d.CheckNoResultCheckpoint(ctx, task.ID, 1, "high")
result <- decision{count, stop, checked, err}
}()
defer func() { cancel(); <-checkerDone }()
waitForNoResultLockWait(t, d, pid)
select {
case got := <-result:
t.Fatalf("checkpoint returned before finding commit: %+v", got)
case <-time.After(500 * time.Millisecond):
}
close(release)
if err := <-writerErr; err != nil {
t.Fatal(err)
}
got := <-result
if got.err != nil || got.count != 1 || got.stop || got.checked == nil {
t.Fatalf("checkpoint after finding commit: %+v", got)
}
stored, err := d.GetTask(task.ID)
if err != nil || stored.StopReason != "" || stored.NoResultCheckedAt == nil || !stored.NoResultCheckedAt.Equal(*got.checked) {
t.Fatalf("persisted checkpoint: %+v, %v", stored, err)
}
}
func TestNoResultCheckpointBlocksLaterFinding(t *testing.T) {
d, task := noResultTestDB(t)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
count, stop, checked, err := d.CheckNoResultCheckpoint(ctx, task.ID, 1, "high")
if err != nil || count != 0 || !stop || checked == nil {
t.Fatalf("initial checkpoint: %d %v %v %v", count, stop, checked, err)
}
tx, err := d.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if err := LockTaskEvidenceTx(tx, task.ID); err != nil {
t.Fatal(err)
}
var pid int
if err := tx.QueryRow(`SELECT pg_backend_pid()`).Scan(&pid); err != nil {
t.Fatal(err)
}
writerErr := make(chan error, 1)
writerDone := make(chan struct{})
go func() {
defer close(writerDone)
writerErr <- d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
_, err := RecordFindingTx(ctx, tx, RecordFindingInput{
TaskID: task.ID, ExplorationID: task.ExplorationID, Severity: "high", Worker: "test",
}, nil)
return err
})
}()
defer func() { cancel(); <-writerDone }()
waitForNoResultLockWait(t, d, pid)
select {
case err := <-writerErr:
t.Fatalf("finding bypassed task lock: %v", err)
case <-time.After(500 * time.Millisecond):
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := <-writerErr; err != nil {
t.Fatal(err)
}
stored, err := d.GetTask(task.ID)
if err != nil || stored.StopReason != StopReasonNoResult || stored.NoResultCheckedAt == nil || !stored.NoResultCheckedAt.Equal(*checked) {
t.Fatalf("later finding changed checkpoint: %+v, %v", stored, err)
}
if count, err := d.CountQualifyingFindings(task.ID, "high"); err != nil || count != 1 {
t.Fatalf("later finding not committed: %d, %v", count, err)
}
if _, _, again, err := d.CheckNoResultCheckpoint(ctx, task.ID, 1, "high"); err != nil || again != nil {
t.Fatalf("checkpoint claimed again: %v, %v", again, err)
}
}
func TestNoResultCheckpointSkipsUnavailableTasks(t *testing.T) {
for _, status := range []string{"done", "failed", "timeout", "deleted", "missing"} {
t.Run(status, func(t *testing.T) {
d, task := noResultTestDB(t)
id := task.ID
switch status {
case "deleted":
_, err := d.Exec(`UPDATE tasks SET deleted_at=clock_timestamp() WHERE id=$1`, id)
if err != nil {
t.Fatal(err)
}
case "missing":
id = -1
default:
if err := d.SetStatus(id, status); err != nil {
t.Fatal(err)
}
}
if count, stop, checked, err := d.CheckNoResultCheckpoint(t.Context(), id, 1, ""); err != nil || count != 0 || stop || checked != nil {
t.Fatalf("unavailable task claimed: %d %v %v %v", count, stop, checked, err)
}
})
}
}
+1 -6
View File
@@ -222,13 +222,8 @@ func (e *Engine) checkNoResultCheckpoint(t *Task) (bool, error) {
if err != nil {
return false, err
}
count, err := e.m.pg.CountQualifyingFindings(id, t.NoResultMinSeverity)
if err != nil {
return false, err
}
minimum := max(1, t.NoResultMinFindings)
stop := count < minimum
checked, err := e.m.pg.MarkNoResultChecked(id, stop)
count, stop, checked, err := e.m.pg.CheckNoResultCheckpoint(context.Background(), id, minimum, t.NoResultMinSeverity)
if err != nil || checked == nil {
return false, err
}
+3 -1
View File
@@ -76,7 +76,9 @@ func (s *Server) findingGroups(w http.ResponseWriter, r *http.Request) {
continue
}
if task, ok := s.m.Task(i64s(*groups[i].TaskID)); ok {
groups[i].TaskStatus = s.resolvedTaskStatus(task)
state := task.lifecycleSnapshot()
groups[i].TaskStatus = s.resolvedTaskStatusFromSnapshot(task, state)
groups[i].TaskStopReason = state.StopReason
}
}
}
+75
View File
@@ -12,9 +12,84 @@ import (
"testing"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
)
func TestFindingGroupsNoResultStopReason(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { m.Close() })
marker := fmt.Sprintf("__no_result_groups_%d__", time.Now().UnixNano())
task, err := m.CreateTask("stopped finding group", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
id := mustTaskID(t, task.ID)
t.Cleanup(func() {
if _, err := m.pg.Exec(`DELETE FROM findings WHERE vulnclass=$1`, marker); err != nil {
t.Error(err)
}
if err := m.pg.DeleteTask(id); err != nil {
t.Error(err)
}
})
for _, taskID := range []int64{id, 0} {
if _, err := m.pg.AddFinding(taskID, 0, marker, "", db.SeverityHigh, "", "", "test", nil); err != nil {
t.Fatal(err)
}
}
engine := NewEngine(m)
engine.SetAgentResolver(func(*Task) (*agent.Planner, *agent.Worker) { return &agent.Planner{}, &agent.Worker{} })
engine.started.Store(task.ID, true)
for _, tc := range []struct {
name, dbStatus, dbReason, liveStatus, liveReason, wantStatus, wantReason string
live bool
}{
{name: "persisted no result", dbStatus: "timeout", dbReason: db.StopReasonNoResult, wantStatus: "timeout", wantReason: db.StopReasonNoResult},
{name: "persisted timeout", dbStatus: "timeout", wantStatus: "timeout"},
{name: "live no result overrides database", dbStatus: "running", live: true, liveStatus: "timeout", liveReason: db.StopReasonNoResult, wantStatus: "timeout", wantReason: db.StopReasonNoResult},
{name: "live timeout clears stale reason", dbStatus: "timeout", dbReason: db.StopReasonNoResult, live: true, liveStatus: "timeout", wantStatus: "timeout"},
{name: "revival clears stale reason", dbStatus: "timeout", dbReason: db.StopReasonNoResult, live: true, liveStatus: "running", wantStatus: "running"},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := m.pg.Exec(`UPDATE tasks SET status=$2, stop_reason=$3 WHERE id=$1`, id, tc.dbStatus, tc.dbReason); err != nil {
t.Fatal(err)
}
task.updateLifecycle(func(s *taskLifecycleState) { s.Status = tc.liveStatus; s.StopReason = tc.liveReason })
s := &Server{m: m}
if tc.live {
s.engine = engine
}
rec := httptest.NewRecorder()
s.findingGroups(rec, httptest.NewRequest(http.MethodGet, "/api/exploration/findings/groups?vulnclass="+marker, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var page struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 2 {
t.Fatalf("groups=%s", rec.Body.String())
}
for _, group := range page.Items {
wantStatus, wantReason := tc.wantStatus, tc.wantReason
if group["task_id"] == nil {
wantStatus, wantReason = "", ""
}
if group["task_status"] != wantStatus || group["task_stop_reason"] != wantReason {
t.Fatalf("group=%+v want status=%q reason=%q", group, wantStatus, wantReason)
}
}
})
}
}
func TestFindingPaginationParam(t *testing.T) {
for _, test := range []struct {
raw, name string
+4
View File
@@ -1288,6 +1288,10 @@ func (s *Server) resolvedTaskStatus(t *Task) string {
return "created"
}
lifecycle := t.lifecycleSnapshot()
return s.resolvedTaskStatusFromSnapshot(t, lifecycle)
}
func (s *Server) resolvedTaskStatusFromSnapshot(t *Task, lifecycle taskLifecycleState) string {
switch {
case isTerminalStatus(lifecycle.Status):
return lifecycle.Status
+7 -1
View File
@@ -1120,7 +1120,13 @@ export default function DashboardPage() {
</Link>
</td>
<td className="px-4 py-3">
<StatusBadge domain="task" value={t.status} dot className="px-1.5 py-0 text-[10px]" />
<StatusBadge
domain="task"
value={t.status}
stopReason={t.stop_reason}
dot
className="px-1.5 py-0 text-[10px]"
/>
</td>
<td className="px-4 py-3">
<StatusBadge
@@ -1167,7 +1167,14 @@ export default function FindingsPage() {
</div>
</button>
<div className="flex flex-wrap items-center gap-2">
{group.task_status && <StatusBadge domain="task" value={group.task_status} dot />}
{group.task_status && (
<StatusBadge
domain="task"
value={group.task_status}
stopReason={group.task_stop_reason}
dot
/>
)}
{SEVERITIES.map((level) => {
const count = group[level];
if (count === 0) return null;
+1
View File
@@ -2102,6 +2102,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
task_name: owner?.name ?? "",
task_description: owner?.description ?? items[0]?.task_description ?? "",
task_status: owner?.status ?? "",
task_stop_reason: owner?.stop_reason ?? "",
count: items.length,
critical: items.filter((finding) => finding.severity === "critical").length,
high: items.filter((finding) => finding.severity === "high").length,
+1
View File
@@ -561,6 +561,7 @@ export interface FindingGroup {
task_name?: string; // 可选任务名称;空/缺省=未命名
task_description: string;
task_status: string;
task_stop_reason?: string;
count: number;
critical: number;
high: number;