af4eeceb6a
`SetMaxOpenConns(1)` under WAL gives up concurrent reads, and the task asked whether that costs anything. Measured over a fixed two-second window, a paced writer against a read loop, three runs per cap: reads do not queue. Four connections buy 70µs at p50 on a turn that spends 1.19s in the resident model, and write throughput more than halves. A 19ms worst case also cannot be the source of the 2.7s router figure, so that line of enquiry is closed. What the cap cannot survive is a long-lived transaction. It holds the only connection, so a second read never completes: two seconds and `context deadline exceeded`, against 1ms at a cap of four. `Store.DB` handed out exactly that transaction. It had been there since the initial commit with no production caller, and its comment described a loop that never materialised. Its one user was a test helper reading `delivery_attempts` by raw SQL, which `ListDeliveryAttempts` has covered since V-390. So the cap stays and the seam goes, and the hazard is gone by construction rather than by documentation. `internal/store/conncap_test.go` stays as the standing measurement, skipped under -short. The comment at the cap and the one in `internal/ipc/server.go` that leans on it now state the invariant and cite the numbers. Measurement: docs/evals/2026-08-07-store-connection-cap.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.7 KiB
Go
157 lines
5.7 KiB
Go
// Package store is maven's persistent state layer.
|
|
//
|
|
// The layer is append-only: a wrong fact is superseded, not overwritten.
|
|
// Current value for a key = the latest non-voided fact row.
|
|
//
|
|
// Schema lives in schema.sql and is applied idempotently on Open.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
_ "embed" // schema.sql
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
//go:embed schema.sql
|
|
var schemaSQL string
|
|
|
|
// FactKind — self | env | config. The loop only evaluates predicates against
|
|
// `self` and `config` rows; `env` feeds env predicates (calendar/weather/health).
|
|
type FactKind string
|
|
|
|
const (
|
|
KindSelf FactKind = "self"
|
|
KindEnv FactKind = "env"
|
|
KindConfig FactKind = "config"
|
|
)
|
|
|
|
// Fact — one observation. ts is valid-time (true-as-of), not insert-time.
|
|
type Fact struct {
|
|
ID int64
|
|
Ts time.Time
|
|
Kind FactKind
|
|
Key string
|
|
Value string // raw json if structured
|
|
Source string // tap:* | infer:* | poll:* | ambient | promote | feedback
|
|
Confidence float64
|
|
VoidsID sql.NullInt64
|
|
|
|
// Subject, EntityID, ResolutionState — entity-aware memory (Vikunja #279).
|
|
// Subject is the free-text "who/what this fact is about" supplied at write
|
|
// time by WriteFactAboutSubject; empty means the fact isn't about a
|
|
// resolvable entity (ResolutionState stays "none"). EntityID is the
|
|
// canonical Nexus entity_id once the enrichment worker resolves Subject.
|
|
Subject string
|
|
EntityID sql.NullString
|
|
ResolutionState FactResolutionState
|
|
}
|
|
|
|
// FactResolutionState — where a fact's Subject stands in Nexus entity
|
|
// resolution. "none" = no subject given (most facts). "pending" = subject
|
|
// given, not yet resolved. Terminal states: "resolved", "ambiguous" (Nexus
|
|
// returned candidates, not stored — matches the ecosystem's ambiguity-blocks
|
|
// invariant), "not_found".
|
|
type FactResolutionState string
|
|
|
|
const (
|
|
ResolutionNone FactResolutionState = "none"
|
|
ResolutionPending FactResolutionState = "pending"
|
|
ResolutionResolved FactResolutionState = "resolved"
|
|
ResolutionAmbiguous FactResolutionState = "ambiguous"
|
|
ResolutionNotFound FactResolutionState = "not_found"
|
|
)
|
|
|
|
// Bucket — presence hysteresis state.
|
|
type Bucket string
|
|
|
|
const (
|
|
Present Bucket = "present"
|
|
Away Bucket = "away"
|
|
)
|
|
|
|
// Store is the persistent state layer. All writes are append-only; nothing
|
|
// here performs an UPDATE of a fact value (status-flips on reminders/nudges
|
|
// are the documented exceptions — they mutate small state-machine columns).
|
|
type Store struct {
|
|
db *sql.DB
|
|
enc *encState // nil ⇒ plaintext store (dev/CI); set ⇒ re-encrypt on Close
|
|
}
|
|
|
|
// openAt opens or creates the sqlite database at path and applies schema +
|
|
// migrations. This is the raw plaintext open used by both Open (plaintext
|
|
// store) and OpenEncrypted (the tmpfs working copy).
|
|
//
|
|
// Pragmas (WAL, NORMAL, FK on, busy_timeout) are set in schema.sql and re-applied
|
|
// per connection on open via the modernc driver DSN.
|
|
func openAt(ctx context.Context, path string) (*sql.DB, error) {
|
|
// `_pragma=busy_timeout(5000)` etc. embed cleanly; schema.sql sets them too.
|
|
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", path)
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
// One connection, so every statement is serialised at the database and no
|
|
// caller above needs a lock of its own. internal/ipc's Server relies on
|
|
// exactly this, which is why the cap is an invariant rather than a tuning
|
|
// knob: raising it moves the serialisation guarantee somewhere it is not
|
|
// written down.
|
|
//
|
|
// Measured on 07-08-2026 (V-642, docs/evals/2026-08-07-store-connection-cap.md).
|
|
// WAL exists to let readers run beside one writer, and the cap gives that
|
|
// up, but reads do not queue: p50 594µs against 525µs at a cap of four,
|
|
// while write throughput more than halves. The one thing the cap cannot
|
|
// survive is a long-lived transaction, which holds the only connection and
|
|
// stalls every read for its lifetime. So the store begins none, and
|
|
// TestConnCap_ReadBlocksBehindOpenSnapshot is the standing measurement of
|
|
// what re-adding one would cost.
|
|
db.SetMaxOpenConns(1)
|
|
if _, err := db.ExecContext(ctx, schemaSQL); err != nil {
|
|
if closeErr := db.Close(); closeErr != nil {
|
|
return nil, fmt.Errorf("apply schema: %w (close: %v)", err, closeErr)
|
|
}
|
|
return nil, fmt.Errorf("apply schema: %w", err)
|
|
}
|
|
if err := migrate(ctx, db); err != nil {
|
|
if closeErr := db.Close(); closeErr != nil {
|
|
return nil, fmt.Errorf("migrate: %w (close: %v)", err, closeErr)
|
|
}
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// Open opens or creates a PLAINTEXT sqlite database at path. Used by tests and
|
|
// by any deployment that keeps the db unencrypted (CI, dev). Production goes
|
|
// through OpenEncrypted.
|
|
func Open(ctx context.Context, path string) (*Store, error) {
|
|
db, err := openAt(ctx, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
// Close checkpoints, releases the database handle, and — for an encrypted
|
|
// store — re-encrypts the tmpfs working copy back to the on-disk ciphertext
|
|
// file atomically, then wipes the plaintext copy and zeroes the key.
|
|
func (s *Store) Close() error {
|
|
if s.enc == nil {
|
|
return s.db.Close()
|
|
}
|
|
return s.enc.closeAndSeal(s.db)
|
|
}
|
|
|
|
var (
|
|
// ErrNoFact — no non-voided row exists for this key.
|
|
ErrNoFact = errors.New("store: no fact for key")
|
|
// ErrConfidence — a write attempted to use a confidence outside (0,1.0].
|
|
ErrConfidence = errors.New("store: confidence must be in (0.0, 1.0]")
|
|
// ErrVoidsMissing — a correction pointed at a nonexistent fact.
|
|
ErrVoidsMissing = errors.New("store: voids_id does not reference an existing fact")
|
|
)
|