655f5f66f8
gates / gates (push) Successful in 6m0s
- S4 / R-242: GoldenVouchChecker - a published golden unvouched for 2 h raises golden_not_vouched (operator only) - S6 / R-244: the delete cascade de-references app_log_issues; one-time sweep flag -sweep-app-log-residue=count|apply - S10 / R-266: read_failed storage entry shows "read failed", not 0 % - S29 / R-250: host-key scan IPv4 first, ~2 min ladder, "safe to press Save again" error - S30 / R-246: stale_at retired (reader, setter, badge) - S32 / R-521: storage_reconnected ends the storage_disconnected cooldown on both channels - S33 / R-402: host page "Last full check (off-site)" line; two wire-gate allowlist entries removed Each with a test seen failing first. Not built or deployed yet (operator-attended deploy). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
183 lines
5.6 KiB
Go
183 lines
5.6 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// R-244 (operator ruling 2026-10-12, S6, `09` §3 decision 201): a deleted customer's id must not
|
|
// survive in `app_log_issues`.
|
|
//
|
|
// The table is a FLEET-WIDE aggregate keyed on app_name+fingerprint, with the customers who hit an
|
|
// issue in `affected_customers` (a JSON list) and, for the row's sample log lines, `context_customer`
|
|
// + `context`. So a delete must DE-REFERENCE, never `DELETE … WHERE customer LIKE`: on 2026-08-08, 27
|
|
// of the 71 rows naming a torn-down venue were shared with a LIVE customer, whose history a naive
|
|
// delete would have destroyed. The rule, per row that names the customer:
|
|
// - remove the id from affected_customers;
|
|
// - if the sample lines were that customer's (context_customer = id), drop them (context, context_customer);
|
|
// - delete the row only when no customer is left in affected_customers.
|
|
// Pinned by TestDereferenceCustomerFromAppIssues and TestSweepDeletedAppIssueRefs.
|
|
|
|
// appIssueRefs are the rows that name a customer, with what de-referencing would do to each.
|
|
type appIssueRef struct {
|
|
id int64
|
|
remaining []string // affected_customers without the id
|
|
ownsCtx bool // context_customer == id
|
|
}
|
|
|
|
func appIssueRefsFor(q interface {
|
|
Query(string, ...any) (*sql.Rows, error)
|
|
}, customerID string) ([]appIssueRef, error) {
|
|
rows, err := q.Query(`SELECT id, COALESCE(affected_customers,'[]'), COALESCE(context_customer,'')
|
|
FROM app_log_issues WHERE context_customer = ? OR instr(affected_customers, ?) > 0`,
|
|
customerID, `"`+jsonEscape(customerID)+`"`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []appIssueRef
|
|
for rows.Next() {
|
|
var id int64
|
|
var affected, ctxCust string
|
|
if err := rows.Scan(&id, &affected, &ctxCust); err != nil {
|
|
return nil, err
|
|
}
|
|
var list []string
|
|
_ = json.Unmarshal([]byte(affected), &list)
|
|
ref := appIssueRef{id: id, ownsCtx: ctxCust == customerID}
|
|
named := ref.ownsCtx
|
|
for _, c := range list {
|
|
if c == customerID {
|
|
named = true
|
|
continue
|
|
}
|
|
ref.remaining = append(ref.remaining, c)
|
|
}
|
|
if named { // instr() is a pre-filter; only an exact list member or the context owner counts
|
|
out = append(out, ref)
|
|
}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func jsonEscape(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return strings.Trim(string(b), `"`)
|
|
}
|
|
|
|
// dereferenceCustomerFromAppIssues applies the rule above inside tx. Returns (rows changed, rows deleted).
|
|
func dereferenceCustomerFromAppIssues(tx *sql.Tx, customerID string) (changed, deleted int, err error) {
|
|
refs, err := appIssueRefsFor(tx, customerID)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
for _, r := range refs {
|
|
if len(r.remaining) == 0 {
|
|
if _, err := tx.Exec(`DELETE FROM app_log_issues WHERE id = ?`, r.id); err != nil {
|
|
return changed, deleted, err
|
|
}
|
|
deleted++
|
|
continue
|
|
}
|
|
left, _ := json.Marshal(r.remaining)
|
|
if r.ownsCtx {
|
|
_, err = tx.Exec(`UPDATE app_log_issues SET affected_customers = ?, context = '', context_customer = '' WHERE id = ?`, string(left), r.id)
|
|
} else {
|
|
_, err = tx.Exec(`UPDATE app_log_issues SET affected_customers = ? WHERE id = ?`, string(left), r.id)
|
|
}
|
|
if err != nil {
|
|
return changed, deleted, err
|
|
}
|
|
changed++
|
|
}
|
|
return changed, deleted, nil
|
|
}
|
|
|
|
// AppIssueResidue is the one-time sweep's plan (or result): per DELETED customer id, how many shared
|
|
// rows would be de-referenced and how many would be deleted.
|
|
type AppIssueResidue struct {
|
|
CustomerID string
|
|
Dereferenced int
|
|
Deleted int
|
|
}
|
|
|
|
// SweepDeletedAppIssueRefs finds every customer id named in app_log_issues that the hub no longer
|
|
// knows at all — no customer_configs row, no hosts row, no reports row — and, when apply is true,
|
|
// de-references it with the rule above. apply=false changes nothing (the count shown to the operator
|
|
// first, S6). A customer the hub still knows by ANY of the three is never touched.
|
|
func (s *Store) SweepDeletedAppIssueRefs(apply bool) ([]AppIssueResidue, error) {
|
|
ids := map[string]bool{}
|
|
rows, err := s.db.Query(`SELECT COALESCE(affected_customers,'[]'), COALESCE(context_customer,'') FROM app_log_issues`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var affected, ctxCust string
|
|
if err := rows.Scan(&affected, &ctxCust); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
var list []string
|
|
_ = json.Unmarshal([]byte(affected), &list)
|
|
for _, c := range list {
|
|
ids[c] = true
|
|
}
|
|
if ctxCust != "" {
|
|
ids[ctxCust] = true
|
|
}
|
|
}
|
|
rows.Close()
|
|
var gone []string
|
|
for id := range ids {
|
|
if id == "" {
|
|
continue
|
|
}
|
|
var n int
|
|
if err := s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM customer_configs WHERE customer_id = ?)
|
|
+ (SELECT COUNT(*) FROM hosts WHERE customer_id = ?)
|
|
+ (SELECT COUNT(*) FROM reports WHERE customer_id = ?)`, id, id, id).Scan(&n); err != nil {
|
|
return nil, fmt.Errorf("sweep: is %q known: %w", id, err)
|
|
}
|
|
if n == 0 {
|
|
gone = append(gone, id)
|
|
}
|
|
}
|
|
sort.Strings(gone)
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
var out []AppIssueResidue
|
|
for _, id := range gone {
|
|
if !apply {
|
|
refs, err := appIssueRefsFor(tx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := AppIssueResidue{CustomerID: id}
|
|
for _, ref := range refs {
|
|
if len(ref.remaining) == 0 {
|
|
r.Deleted++
|
|
} else {
|
|
r.Dereferenced++
|
|
}
|
|
}
|
|
out = append(out, r)
|
|
continue
|
|
}
|
|
ch, del, err := dereferenceCustomerFromAppIssues(tx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sweep %s: %w", id, err)
|
|
}
|
|
out = append(out, AppIssueResidue{CustomerID: id, Dereferenced: ch, Deleted: del})
|
|
}
|
|
if !apply {
|
|
return out, nil
|
|
}
|
|
return out, tx.Commit()
|
|
}
|