Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

244 lines
7.3 KiB
Go

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
}