package store_sqlite import ( "database/sql" "github.com/zzet/gortex/internal/graph" ) // Compile-time assertions that the SQLite Store satisfies the optional // per-file mtime persistence capabilities. Lifting this state into the // same backend the graph lives in means warm restarts read it through // one persistence surface instead of a second gob snapshot. var ( _ graph.FileMtimeWriter = (*Store)(nil) _ graph.FileMtimeReader = (*Store)(nil) _ graph.FileMtimeReplacer = (*Store)(nil) _ graph.FileMtimeDeleter = (*Store)(nil) ) // mtimeChunk bounds how many (repo_prefix, file_path, mtime_ns) tuples // ride in a single multi-row INSERT. SQLite's default compiled-in host // parameter limit is 999; at 3 params per row that caps a statement at // 333 rows, so 300 leaves headroom. const mtimeChunk = 300 // SetFileMtime records one file's modification time (nanoseconds since // the epoch) for a repo prefix, replacing any prior value. It is a // convenience single-row form of BulkSetFileMtimes. func (s *Store) SetFileMtime(repoPrefix, filePath string, mtimeNs int64) error { s.writeMu.Lock() defer s.writeMu.Unlock() _, err := s.db.Exec( `INSERT OR REPLACE INTO file_mtimes (repo_prefix, file_path, mtime_ns) VALUES (?, ?, ?)`, repoPrefix, filePath, mtimeNs, ) return err } // BulkSetFileMtimes persists every (filePath -> mtimeNs) entry for one // repo prefix in a single transaction, chunked so no statement exceeds // SQLite's host-parameter limit. Idempotent on (repoPrefix, filePath): // re-running with overlapping keys replaces in place. Empty input is a // no-op. func (s *Store) BulkSetFileMtimes(repoPrefix string, mtimes map[string]int64) error { if len(mtimes) == 0 { 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 if err := insertMtimesTx(tx, repoPrefix, mtimes); err != nil { return err } return tx.Commit() } // ReplaceFileMtimes persists the AUTHORITATIVE full mtime set for one repo // prefix: every prior row for the prefix is dropped and the supplied set is // written, all in one transaction. The full-index persist path uses this so // files deleted since the last index are pruned — BulkSetFileMtimes (upsert) // would leave their rows behind, and warm-restart reconcile would then // detect them as phantom deletions on every restart, forcing a full // re-track that never converges. // // Empty input is a deliberate no-op: it never wipes a repo's mtimes from an // empty snapshot (the indexer guards the call with len(snapshot) > 0). func (s *Store) ReplaceFileMtimes(repoPrefix string, mtimes map[string]int64) error { if len(mtimes) == 0 { 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 if _, err := tx.Exec(`DELETE FROM file_mtimes WHERE repo_prefix = ?`, repoPrefix); err != nil { return err } if err := insertMtimesTx(tx, repoPrefix, mtimes); err != nil { return err } return tx.Commit() } // DeleteFileMtimes drops the rows for a set of repo-relative file paths // under one repo prefix — the incremental-reindex sibling of // ReplaceFileMtimes. The watcher / incremental path calls it when a file is // deleted so the persisted set stays in step with the live graph and the // next warm restart does not see the path as a phantom deletion. Empty // input is a no-op. func (s *Store) DeleteFileMtimes(repoPrefix string, paths []string) error { if len(paths) == 0 { 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 // Chunk so the IN-list never exceeds SQLite's host-parameter limit: // one leading repo_prefix arg + up to mtimeChunk path args per stmt. for start := 0; start < len(paths); start += mtimeChunk { end := min(start+mtimeChunk, len(paths)) batch := paths[start:end] args := make([]any, 0, len(batch)+1) args = append(args, repoPrefix) stmt := make([]byte, 0, 64+len(batch)*2) stmt = append(stmt, "DELETE FROM file_mtimes WHERE repo_prefix = ? AND file_path IN ("...) for i := range batch { if i > 0 { stmt = append(stmt, ',') } stmt = append(stmt, '?') args = append(args, batch[i]) } stmt = append(stmt, ')') if _, err := tx.Exec(string(stmt), args...); err != nil { return err } } return tx.Commit() } // insertMtimesTx writes every (path -> ns) entry for repoPrefix into the // given transaction with chunked multi-row INSERT OR REPLACE statements, // each kept under SQLite's host-parameter limit. The caller owns the tx // lifecycle (Begin/Commit/Rollback) and the write lock. func insertMtimesTx(tx *sql.Tx, repoPrefix string, mtimes map[string]int64) error { // Stable ordering is not required for correctness, but iterating the // map directly is fine — we only chunk by count. type kv struct { path string ns int64 } pending := make([]kv, 0, len(mtimes)) for p, ns := range mtimes { pending = append(pending, kv{path: p, ns: ns}) } for start := 0; start < len(pending); start += mtimeChunk { end := min(start+mtimeChunk, len(pending)) batch := pending[start:end] // Build a multi-row INSERT OR REPLACE: (?, ?, ?), (?, ?, ?), ... args := make([]any, 0, len(batch)*3) stmt := make([]byte, 0, 64+len(batch)*16) stmt = append(stmt, "INSERT OR REPLACE INTO file_mtimes (repo_prefix, file_path, mtime_ns) VALUES "...) for i, e := range batch { if i > 0 { stmt = append(stmt, ',') } stmt = append(stmt, "(?, ?, ?)"...) args = append(args, repoPrefix, e.path, e.ns) } if _, err := tx.Exec(string(stmt), args...); err != nil { return err } } return nil } // LoadFileMtimes returns the recorded mtimes for one repo prefix as a // fresh map. Returns nil when there is no data for the prefix (the // "no recorded state" signal warmup expects). func (s *Store) LoadFileMtimes(repoPrefix string) map[string]int64 { rows, err := s.db.Query( `SELECT file_path, mtime_ns FROM file_mtimes WHERE repo_prefix = ?`, repoPrefix, ) if err != nil { return nil } defer rows.Close() var out map[string]int64 for rows.Next() { var path string var ns int64 if err := rows.Scan(&path, &ns); err != nil { return nil } if out == nil { out = make(map[string]int64) } out[path] = ns } if err := rows.Err(); err != nil { return nil } return out } // FileMtimes is a fallible read form of LoadFileMtimes. It always // returns a non-nil (possibly empty) map for a known/unknown prefix and // surfaces any query error. The interface method LoadFileMtimes is the // daemon's entry point; this variant exists for callers (and tests) // that want the error and an always-materialised map. func (s *Store) FileMtimes(repoPrefix string) (map[string]int64, error) { rows, err := s.db.Query( `SELECT file_path, mtime_ns FROM file_mtimes WHERE repo_prefix = ?`, repoPrefix, ) if err != nil { return nil, err } defer rows.Close() out := make(map[string]int64) for rows.Next() { var path string var ns int64 if err := rows.Scan(&path, &ns); err != nil { return nil, err } out[path] = ns } if err := rows.Err(); err != nil { return nil, err } return out, nil }