Files
felhom.eu/hub/internal/store/app_log_residue.go
T
admin 655f5f66f8
gates / gates (push) Successful in 6m0s
hub v0.147.0 (code): the operator's answers S4, S6, S10, S29, S30, S32, S33
- 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
2026-10-12 14:54:14 +02:00

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()
}