Skip to content
quarry Artifact [eea005d907]
Login

Artifact [eea005d907]

Artifact eea005d907157a08b6059981edc6897b248baafd3af3631855961403c9ff4ccd:


//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
		}
	}
}