a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
311 lines
12 KiB
Go
311 lines
12 KiB
Go
package store_sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// This file adds the repo-scoped store hygiene the base eviction path lacks.
|
|
// EvictRepo (store.go) deletes ONLY nodes+edges, but a repo owns fifteen
|
|
// other repo_prefix-keyed sidecar tables (file_mtimes, repo_index_state,
|
|
// enrichment_state, clone_shingles, constant_values, files, ref_facts,
|
|
// vectors, churn/coverage/release/blame_enrichment, symbol_fts,
|
|
// symbol_fts_rowid, content_fts — see schema.go). Untracking a repo through
|
|
// EvictRepo leaks every one of them, so a long-lived store accumulates
|
|
// sidecar rows for repos removed from config long ago. PurgeRepo clears a
|
|
// repo whole; OrphanRepoPrefixes finds prefixes that outlived their config
|
|
// entry; RekeyRepoPrefix moves a lone repo's residue when it earns a prefix.
|
|
//
|
|
// INVARIANT — the empty repo_prefix is NEVER purged. In a live multi-repo
|
|
// store repo_prefix='' identifies SYNTHETIC GLOBAL EXTERNALS (external_call
|
|
// ::dep:* / builtin:: / module:: nodes shared across every repo) and, in a
|
|
// single-repo store, the sole repo's live data. Deleting '' rows would strip
|
|
// the shared externals out from under every repo, or wipe the lone repo.
|
|
// Every method here refuses or excludes ''.
|
|
|
|
// purgeSidecarTables are the repo_prefix-keyed sidecar tables PurgeRepo
|
|
// clears for a prefix, alongside nodes+edges. Each carries a repo_prefix
|
|
// column a plain `DELETE ... WHERE repo_prefix = ?` keys on. The two FTS5
|
|
// vtables (symbol_fts, content_fts) carry repo_prefix UNINDEXED, so their
|
|
// delete is a full scan — acceptable for a purge (a rare, whole-repo op),
|
|
// unlike the per-edit hot path. `vectors` is deliberately absent: it has NO
|
|
// repo_prefix column (keyed by node_id alone), so PurgeRepo deletes its rows
|
|
// by node-id membership instead (see deleteByIDColumnsTx below).
|
|
var purgeSidecarTables = []string{
|
|
"file_mtimes",
|
|
"repo_index_state",
|
|
"enrichment_state",
|
|
"clone_shingles",
|
|
"constant_values",
|
|
"files",
|
|
"ref_facts",
|
|
"churn_enrichment",
|
|
"coverage_enrichment",
|
|
"release_enrichment",
|
|
"blame_enrichment",
|
|
"symbol_fts",
|
|
"symbol_fts_rowid",
|
|
"content_fts",
|
|
}
|
|
|
|
// PurgeRepo deletes EVERY row a repo owns — nodes, edges, and all fifteen
|
|
// repo_prefix-keyed sidecar tables (purgeSidecarTables + vectors) — in one
|
|
// transaction. It is the complete form of EvictRepo (which drops only
|
|
// nodes+edges), wired into UntrackRepo so removing a repo from config leaves
|
|
// no residue. Refuses prefix=="" (shared global externals / solo-mode live
|
|
// data — see the file-level INVARIANT).
|
|
func (s *Store) PurgeRepo(prefix string) error {
|
|
if prefix == "" {
|
|
return fmt.Errorf("store_sqlite: PurgeRepo refuses empty repo prefix (would delete shared global externals / solo-repo data)")
|
|
}
|
|
|
|
s.writeMu.Lock()
|
|
defer s.writeMu.Unlock()
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // rollback after Commit is a no-op
|
|
|
|
// Collect this repo's node IDs first: edges and vectors are keyed off
|
|
// them (edges by from_id/to_id, vectors by node_id — neither carries a
|
|
// repo_prefix column). Edge deletion semantics mirror evictByScopeLocked
|
|
// (store.go): delete every edge touching one of these nodes, then the
|
|
// nodes themselves.
|
|
ids, err := repoNodeIDsTx(tx, prefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := deleteByIDColumnsTx(tx, "edges", []string{"from_id", "to_id"}, ids); err != nil {
|
|
return fmt.Errorf("store_sqlite: PurgeRepo edges: %w", err)
|
|
}
|
|
if err := deleteByIDColumnsTx(tx, "vectors", []string{"node_id"}, ids); err != nil {
|
|
return fmt.Errorf("store_sqlite: PurgeRepo vectors: %w", err)
|
|
}
|
|
|
|
for _, table := range purgeSidecarTables {
|
|
if _, err := tx.Exec(`DELETE FROM `+table+` WHERE repo_prefix = ?`, prefix); err != nil {
|
|
return fmt.Errorf("store_sqlite: PurgeRepo %s: %w", table, err)
|
|
}
|
|
}
|
|
|
|
if _, err := tx.Exec(`DELETE FROM nodes WHERE repo_prefix = ?`, prefix); err != nil {
|
|
return fmt.Errorf("store_sqlite: PurgeRepo nodes: %w", err)
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// orphanScanTables are the tables OrphanRepoPrefixes unions DISTINCT
|
|
// repo_prefix over. These five span the residue space: nodes (the primary
|
|
// keyed store), file_mtimes + repo_index_state (the warm-restart provenance
|
|
// that lingers when nodes are gone but sidecars survive — the exact shape a
|
|
// leaked untrack leaves), enrichment_state (per-provider provenance), and
|
|
// files (per-file metadata). A prefix whose nodes are gone but whose
|
|
// sidecars remain is invisible to a nodes-only scan, which is why the
|
|
// sidecar tables are unioned in; scanning still more tables would only
|
|
// rediscover the same prefixes at higher cost.
|
|
var orphanScanTables = []string{
|
|
"nodes",
|
|
"file_mtimes",
|
|
"repo_index_state",
|
|
"enrichment_state",
|
|
"files",
|
|
}
|
|
|
|
// OrphanRepoPrefixes returns every repo_prefix present in the store but
|
|
// absent from known — repos whose rows outlived their config entry (an
|
|
// untrack that predated PurgeRepo, or a repo dropped straight from config
|
|
// with no untrack at all). The empty prefix is NEVER reported (shared global
|
|
// externals / solo data). known is matched case-insensitively as a safety
|
|
// net, so a case-only spelling drift on a case-insensitive filesystem can
|
|
// never flag a still-tracked repo as an orphan (the #270 failure mode).
|
|
// Startup warmup feeds the result to PurgeRepo.
|
|
func (s *Store) OrphanRepoPrefixes(known []string) []string {
|
|
knownFold := make(map[string]struct{}, len(known))
|
|
for _, k := range known {
|
|
if k == "" {
|
|
continue
|
|
}
|
|
knownFold[strings.ToLower(k)] = struct{}{}
|
|
}
|
|
|
|
seen := make(map[string]struct{})
|
|
var out []string
|
|
for _, table := range orphanScanTables {
|
|
// WHERE repo_prefix <> '' both excludes the protected empty prefix
|
|
// and lets the nodes scan ride the partial nodes_by_repo index
|
|
// (defined WHERE repo_prefix <> ''). A table absent on an older
|
|
// schema simply contributes nothing.
|
|
rows, err := s.db.Query(`SELECT DISTINCT repo_prefix FROM ` + table + ` WHERE repo_prefix <> ''`)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for rows.Next() {
|
|
var p string
|
|
if err := rows.Scan(&p); err != nil {
|
|
break
|
|
}
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if _, ok := knownFold[strings.ToLower(p)]; ok {
|
|
continue
|
|
}
|
|
if _, dup := seen[p]; dup {
|
|
continue
|
|
}
|
|
seen[p] = struct{}{}
|
|
out = append(out, p)
|
|
}
|
|
_ = rows.Close()
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rekeyMoveTables are the sidecar tables RekeyRepoPrefix relabels from old
|
|
// to new. Every one is keyed by repo_prefix (+ file_path or provider), NOT
|
|
// by node_id, so its row content survives a node-id change: file_mtimes /
|
|
// files by (repo_prefix, file_path); repo_index_state / enrichment_state by
|
|
// repo_prefix (+ provider). At a solo->multi migration every '' row in these
|
|
// belongs to the one migrating repo — global externals live in the NODES
|
|
// table and hold NO rows here — so moving them wholesale is safe. UPDATE OR
|
|
// REPLACE folds any row the re-mint re-index already wrote under new
|
|
// (identical content: same files, same mtimes, same commit) instead of
|
|
// tripping the primary-key conflict a plain UPDATE would.
|
|
var rekeyMoveTables = []string{
|
|
"file_mtimes",
|
|
"files",
|
|
"repo_index_state",
|
|
"enrichment_state",
|
|
}
|
|
|
|
// rekeyDropTables are the sidecar tables RekeyRepoPrefix DROPS (rather than
|
|
// relabels) for old. Every one is keyed by node_id, and the solo->multi
|
|
// re-mint changes every node id (unprefixed `pkg::X` -> `<new>::pkg::X`), so
|
|
// these old-id rows are already dangling against the evicted unprefixed
|
|
// nodes. Relabeling their repo_prefix would just move dangling rows under
|
|
// new — and let, e.g., the clone reseed load a shingle set for a node that
|
|
// no longer exists. Dropping them is correct: the re-mint re-index rewrites
|
|
// the index-time sidecars (constant_values, ref_facts, clone_shingles) under
|
|
// the new node ids, and the enrichment sidecars (churn/coverage/release/
|
|
// blame) must re-run for the new ids regardless. The FTS vtables sit here
|
|
// too — their rows carry the old node ids, and UPDATE over an FTS5 UNINDEXED
|
|
// column is awkward, so delete-then-reindex is the clean path.
|
|
var rekeyDropTables = []string{
|
|
"clone_shingles",
|
|
"constant_values",
|
|
"ref_facts",
|
|
"churn_enrichment",
|
|
"coverage_enrichment",
|
|
"release_enrichment",
|
|
"blame_enrichment",
|
|
"symbol_fts",
|
|
"symbol_fts_rowid",
|
|
"content_fts",
|
|
}
|
|
|
|
// RekeyRepoPrefix moves a repo's sidecar residue from old to new the moment a
|
|
// solo (unprefixed) repo earns a real prefix because a second repo joined —
|
|
// the migrateLoneUnprefixedRepoCtx path. The prefix/path-keyed provenance
|
|
// tables (rekeyMoveTables) are relabeled so warm restart finds the repo's
|
|
// mtimes + freshness under new instead of full-re-tracking it; the
|
|
// node_id-keyed tables (rekeyDropTables) are dropped because the re-mint
|
|
// changed every node id out from under them (see the two table lists for the
|
|
// per-table rationale).
|
|
//
|
|
// Refuses new=="" (cannot rekey INTO the protected empty prefix). old=="" IS
|
|
// allowed — that is the whole point, since solo repos index unprefixed — and
|
|
// is safe here because this method touches SIDECAR tables ONLY; the synthetic
|
|
// global externals that also carry repo_prefix='' live in the NODES table,
|
|
// which RekeyRepoPrefix never writes.
|
|
func (s *Store) RekeyRepoPrefix(oldPrefix, newPrefix string) error {
|
|
if newPrefix == "" {
|
|
return fmt.Errorf("store_sqlite: RekeyRepoPrefix refuses empty destination prefix")
|
|
}
|
|
if oldPrefix == newPrefix {
|
|
return nil
|
|
}
|
|
|
|
s.writeMu.Lock()
|
|
defer s.writeMu.Unlock()
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // rollback after Commit is a no-op
|
|
|
|
for _, table := range rekeyMoveTables {
|
|
if _, err := tx.Exec(`UPDATE OR REPLACE `+table+` SET repo_prefix = ? WHERE repo_prefix = ?`, newPrefix, oldPrefix); err != nil {
|
|
return fmt.Errorf("store_sqlite: RekeyRepoPrefix move %s: %w", table, err)
|
|
}
|
|
}
|
|
for _, table := range rekeyDropTables {
|
|
if _, err := tx.Exec(`DELETE FROM `+table+` WHERE repo_prefix = ?`, oldPrefix); err != nil {
|
|
return fmt.Errorf("store_sqlite: RekeyRepoPrefix drop %s: %w", table, err)
|
|
}
|
|
}
|
|
// vectors is intentionally omitted: it has NO repo_prefix column (keyed
|
|
// by node_id alone), so it cannot be addressed here by prefix. Any ''
|
|
// embeddings are node_id-keyed against now-evicted unprefixed ids —
|
|
// dangling, and absent in the common case (embeddings are opt-in). They
|
|
// are left to a node-membership vector GC rather than guessed at here.
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// repoNodeIDsTx returns every node id in repoPrefix, read inside tx. The
|
|
// caller holds writeMu. Rows are fully drained + closed before the caller
|
|
// issues writes on the same tx — SQLite forbids an open read cursor while
|
|
// writing on the same connection.
|
|
func repoNodeIDsTx(tx *sql.Tx, repoPrefix string) ([]string, error) {
|
|
rows, err := tx.Query(`SELECT id FROM nodes WHERE repo_prefix = ?`, repoPrefix)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
_ = rows.Close()
|
|
return nil, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
_ = rows.Close()
|
|
return nil, err
|
|
}
|
|
_ = rows.Close()
|
|
return ids, nil
|
|
}
|
|
|
|
// deleteByIDColumnsTx deletes rows from table where ANY of cols matches one
|
|
// of ids, chunked so each statement stays under SQLite's 999 bound-variable
|
|
// limit. Mirrors evictByScopeLocked's chunked from_id/to_id edge delete
|
|
// (store.go) — the semantics source for edge eviction. Empty ids is a no-op.
|
|
func deleteByIDColumnsTx(tx *sql.Tx, table string, cols, ids []string) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
const chunk = 900
|
|
for _, col := range cols {
|
|
for start := 0; start < len(ids); start += chunk {
|
|
end := minInt(start+chunk, len(ids))
|
|
batch := ids[start:end]
|
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")
|
|
args := make([]any, len(batch))
|
|
for i, id := range batch {
|
|
args[i] = id
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM `+table+` WHERE `+col+` IN (`+placeholders+`)`, args...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|