Artifact d117c1faac44e0708547c7c29b371ab4464e87296872235edc8aae5202216c55:
- File
internal/obsduck/obsduck.go
— part of check-in
[e2b74749b4]
at
2026-08-25 22:19:15
on branch fleet-census
— Analytics: say whether history is being kept, and stop keeping it for ever
The live panels read in-memory rings, so a forge whose store failed to open looked exactly like a healthy one. Sink.Err() existed and nothing asked it. The page now distinguishes three states: off, broken, and recording to a named path.
Retention was 'append for ever', which is a default nobody was asked about. --analytics-keep sets the window, 14d by default, 0 keeps everything. Pruned at startup and daily. (user: dmestas, size: 7333) [annotate] [blame] [check-ins using] [more...]
//go:build !js && !wasip1 // Package obsduck stores observations for analysis. // // It is separate from internal/obs, and behind a build constraint, for // one reason: DuckDB requires cgo and refuses to build for wasm. Both // were measured rather than assumed — CGO_ENABLED=0 fails on // go-duckdb's bindings, and GOOS=js reports "build constraints exclude // all Go files". quarry is otherwise CGO-free on purpose (its SQLite // driver is a wasm build for exactly this reason), so that the forge // can theoretically compile to wasm for an embedded UI. That build // ships no analytics, wires no sink, and must never reach this package. // // Nothing on the request path may import this. obs.Sink is the seam; // only the composition root knows which implementation exists. // // Columnar because the workload is columnar: append-only, time-ordered, // aggregate-queried, never point-looked-up by a page. RFC 0001 defers // the lake to "when hydration and analytics — the things columnar is // for — exist", and reserves the operational surface for SQLite. This // is that analytics workload, not a second operational store. package obsduck import ( "database/sql" "fmt" "sync" "time" _ "github.com/marcboeker/go-duckdb/v2" "craftdesign.group/quarry/internal/obs" ) // Sink writes observations to a DuckDB file. // // Buffered, never a write per request: an analytics store on the hot // path would be the forge paying at serve time for a question nobody is // asking yet. Rows accumulate in memory and land in batches, which is // also the shape a Parquet COPY TO wants when the lake arrives. type Sink struct { db *sql.DB mu sync.Mutex renders []obs.Render subs []obs.Subprocess batch int closed bool flushErr error } // Open creates or opens the analytics store at path. func Open(path string, batch int) (*Sink, error) { if batch <= 0 { batch = 256 } db, err := sql.Open("duckdb", path) if err != nil { return nil, fmt.Errorf("obsduck: open %s: %w", path, err) } if err := db.Ping(); err != nil { db.Close() return nil, fmt.Errorf("obsduck: open %s: %w", path, err) } // Wide, flat and denormalised, which is what columnar scans want and // what a Parquet export wants. No indexes and no keys: nothing here // is looked up by identity, and a row is never updated. for _, ddl := range []string{ // ts, not "at": AT is reserved in DuckDB (AT TIME ZONE, time // travel), and a quoted identifier would need quoting at every // call site and in every ad-hoc query someone writes later. `CREATE TABLE IF NOT EXISTS render ( ts TIMESTAMP, path VARCHAR, repo VARCHAR, elapsed_ms BIGINT, ops_ms BIGINT, zit_ms BIGINT, status INTEGER)`, `CREATE TABLE IF NOT EXISTS subprocess ( ts TIMESTAMP, verb VARCHAR, repo VARCHAR, duration_ms BIGINT, failed BOOLEAN)`, } { if _, err := db.Exec(ddl); err != nil { db.Close() return nil, fmt.Errorf("obsduck: schema: %w", err) } } return &Sink{db: db, batch: batch}, nil } // Render buffers one page. func (s *Sink) Render(v obs.Render) { s.mu.Lock() s.renders = append(s.renders, v) full := len(s.renders) >= s.batch s.mu.Unlock() if full { s.Flush() } } // Subprocess buffers one store call. func (s *Sink) Subprocess(v obs.Subprocess) { s.mu.Lock() s.subs = append(s.subs, v) full := len(s.subs) >= s.batch s.mu.Unlock() if full { s.Flush() } } // Flush writes what is buffered. Safe to call at any time and from any // goroutine; a failure is REMEMBERED rather than returned, because the // callers are observation funnels that must not care whether analytics // is healthy. func (s *Sink) Flush() { s.mu.Lock() renders, subs := s.renders, s.subs s.renders, s.subs = nil, nil s.mu.Unlock() if len(renders) == 0 && len(subs) == 0 { return } if err := s.write(renders, subs); err != nil { s.mu.Lock() s.flushErr = err s.mu.Unlock() } } func (s *Sink) write(renders []obs.Render, subs []obs.Subprocess) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if len(renders) > 0 { st, err := tx.Prepare(`INSERT INTO render VALUES (?, ?, ?, ?, ?, ?, ?)`) if err != nil { return err } for _, v := range renders { if _, err := st.Exec(v.At, v.Path, v.Repo, v.Elapsed.Milliseconds(), v.Ops.Milliseconds(), v.Zit.Milliseconds(), v.Status); err != nil { st.Close() return err } } st.Close() } if len(subs) > 0 { st, err := tx.Prepare(`INSERT INTO subprocess VALUES (?, ?, ?, ?, ?)`) if err != nil { return err } for _, v := range subs { if _, err := st.Exec(v.At, v.Verb, v.Repo, v.Duration.Milliseconds(), v.Err); err != nil { st.Close() return err } } st.Close() } return tx.Commit() } // Prune drops observations older than keep and reports how many rows // went. A keep of zero or less keeps everything. // // Retention is the operator's call, not a default nobody was asked // about — but "append for ever" is also a default nobody was asked // about, and it is the one that ends with a forge whose disk fills // because of the thing that was supposed to be watching it. So there // is a window, it is stated, and it can be switched off. func (s *Sink) Prune(keep time.Duration) (int64, error) { if keep <= 0 { return 0, nil } // Flush first: rows still in the buffer are the newest ones and // would survive anyway, but pruning around an unflushed buffer // makes the count reported to an operator a guess. s.Flush() cutoff := time.Now().Add(-keep) var total int64 for _, table := range []string{"render", "subprocess"} { res, err := s.db.Exec(`DELETE FROM `+table+` WHERE ts < ?`, cutoff) if err != nil { return total, fmt.Errorf("obsduck: prune %s: %w", table, err) } if n, err := res.RowsAffected(); err == nil { total += n } } return total, nil } // PruneEvery runs Prune on a timer. Daily is the right cadence for a // window measured in days: pruning more often costs writes to delete // rows that a later pass would have deleted anyway. func (s *Sink) PruneEvery(every, keep time.Duration, stop <-chan struct{}) { if every <= 0 { every = 24 * time.Hour } t := time.NewTicker(every) defer t.Stop() for { select { case <-t.C: if _, err := s.Prune(keep); err != nil { s.mu.Lock() s.flushErr = err s.mu.Unlock() } case <-stop: return } } } // Err reports the last write failure, so an admin surface can say the // analytics store is unhealthy rather than silently recording nothing. func (s *Sink) Err() error { s.mu.Lock() defer s.mu.Unlock() return s.flushErr } // Close flushes and shuts down. func (s *Sink) Close() error { s.mu.Lock() if s.closed { s.mu.Unlock() return nil } s.closed = true s.mu.Unlock() s.Flush() return s.db.Close() } // FlushEvery drains the buffer on a timer, so a quiet forge still lands // its rows. Without it a low-traffic instance would hold observations // in memory until a batch filled, which on a personal forge could be // days — and a restart would lose exactly the window most worth having. func (s *Sink) FlushEvery(d time.Duration, stop <-chan struct{}) { if d <= 0 { d = 30 * time.Second } t := time.NewTicker(d) defer t.Stop() for { select { case <-t.C: s.Flush() case <-stop: s.Flush() return } } }