| index 797b08494..24192db6d 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -10,6 +10,7 @@ |
| package registry |
| |
| import ( |
| + "crypto/sha256" |
| "database/sql" |
| "fmt" |
| "regexp" |
| @@ -984,18 +985,22 @@ WHERE kind = 'instance' AND next_due > 0 AND next_due <= ? ORDER BY host`, now) |
| } |
| |
| // Fingerprint summarizes the route table over a fresh connection, for the |
| -// edge's cheap change poll. It must change on any row insert/delete and on |
| -// the column writes reloads care about; it must see other processes' commits |
| -// (fresh connection, same WAL-snapshot quirk as List). |
| +// edge's cheap change poll. It is content-accurate over the reload-relevant |
| +// columns (status, socket, addr, version, hibernate, channel, backing, |
| +// tunnel): it hashes the actual values, not a length-weighted aggregate, so |
| +// two DIFFERENT values of the SAME length (channel canary/1 -> canary/2, a |
| +// git short-sha version bump) change the string exactly like any other |
| +// write. `grants` stays deliberately excluded (registry.go:93's rule: grants |
| +// change unit shape, not routing). Row count is folded into the result so an |
| +// empty table and a never-opened one still differ from a populated one. |
| +// Still a fresh connection per poll — it must see other processes' commits |
| +// (same WAL-snapshot quirk as List). |
| // |
| -// Text columns contribute their LENGTH under a per-column weight, so a rewrite |
| -// to a DIFFERENT value of the SAME length (channel canary/1 -> canary/2, |
| -// tunnel pi-box -> hq-box) is invisible to this poll. That is a deliberate |
| -// cost of keeping it a single cheap aggregate: an operator's `carlos route` |
| -// runs beside an edge they can SIGHUP, and every same-length rewrite reaches |
| -// the fleet on the next reload for any other reason. Weights (3,5,7,11,13…) |
| -// keep DIFFERENT columns from cancelling each other out, which is the failure |
| -// that would actually hide a change forever. |
| +// Closes #15: the edge's X-Carlos-Version response header reads Version off |
| +// the route the fingerprint gated in, and git short shas are same-length by |
| +// construction — the old length-weighted aggregate could not see a version |
| +// bump land, so the header would serve stale until an unrelated column |
| +// happened to change too. |
| func (r *Registry) Fingerprint() (string, error) { |
| db, err := sql.Open("sqlite", r.path) |
| if err != nil { |
| @@ -1005,12 +1010,27 @@ func (r *Registry) Fingerprint() (string, error) { |
| if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil { |
| return "", err |
| } |
| - var fp string |
| - err = db.QueryRow(` |
| -SELECT COUNT(*) || ':' || COALESCE(MAX(rowid),0) || ':' || |
| - COALESCE(SUM(rowid*17 + LENGTH(status)*3 + LENGTH(socket) + LENGTH(addr) |
| - + LENGTH(version)*5 + hibernate*7 + LENGTH(channel) |
| - + LENGTH(backing)*11 + LENGTH(tunnel)*13),0) |
| -FROM routes`).Scan(&fp) |
| - return fp, err |
| + rows, err := db.Query(` |
| +SELECT rowid, status, socket, addr, version, hibernate, channel, backing, tunnel |
| +FROM routes ORDER BY rowid`) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer rows.Close() |
| + h := sha256.New() |
| + n := 0 |
| + for rows.Next() { |
| + var rowid, hibernate int64 |
| + var status, socket, addr, version, channel, backing, tunnel string |
| + if err := rows.Scan(&rowid, &status, &socket, &addr, &version, &hibernate, &channel, &backing, &tunnel); err != nil { |
| + return "", err |
| + } |
| + fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1e", |
| + rowid, status, socket, addr, version, hibernate, channel, backing, tunnel) |
| + n++ |
| + } |
| + if err := rows.Err(); err != nil { |
| + return "", err |
| + } |
| + return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil |
| } |