Artifact eea005d907157a08b6059981edc6897b248baafd3af3631855961403c9ff4ccd:
- File
internal/obsduck/obsduck.go
— part of check-in
[564f272d3c]
at
2026-08-25 19:24:56
on branch ops-history
— ops: history, not just a snapshot
The rings die with the process, and this fleet restarts constantly — a 4.24s render and a 546 load average both left no trace. obs.Sink had one implementation and it did nothing; this is the durable one.
DuckDB 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. Wide flat tables, no keys and no indexes, which is also what a Parquet COPY TO wants when the lake arrives.
Isolated behind a build constraint, in its own package, imported only by the composition root. Both halves of the reason were measured rather than assumed: CGO_ENABLED=0 fails on go-duckdb's bindings, and GOOS=js reports 'build constraints exclude all Go files'. Leaving the import off the request path is not enough — any reachable import breaks a wasm build — so cmd/quarry has two files, one per side of the tag. Verified: GOOS=js GOARCH=wasm builds cmd/quarry clean at 76MB, without DuckDB.
Buffered, never a write per request, and flushed on a timer so a quiet forge still lands rows rather than holding them in memory for days.
Failure is not fatal. The first run failed on 'at' being reserved in DuckDB; the server started anyway, the panels worked, and the log said what was wrong. Analytics is derived data about a forge that works without it.
Costs, measured: +462MB module cache, binary 57MB -> 104MB, cold build +10s, and CGO_ENABLED=0 no longer builds the server. (user: dmestas, size: 5702) [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() } // 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 } } }