-
Notifications
You must be signed in to change notification settings - Fork 28
feat: clean stale and orphaned indexes #180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| // Copyright 2026 Aeneas Rekkas | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package cmd | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "path/filepath" | ||
| "time" | ||
|
|
||
| "github.com/ory/lumen/internal/config" | ||
| "github.com/ory/lumen/internal/indexlock" | ||
| "github.com/ory/lumen/internal/store" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| // defaultCleanDays is how long an index may go unused before `lumen clean` | ||
| // removes it. | ||
| const defaultCleanDays = 30 | ||
|
|
||
| func init() { | ||
| addCleanFlags(cleanCmd) | ||
| rootCmd.AddCommand(cleanCmd) | ||
| } | ||
|
|
||
| // addCleanFlags registers the clean flags. Shared with the tests so the flag | ||
| // definition never drifts from what runClean reads. | ||
| func addCleanFlags(cmd *cobra.Command) { | ||
| cmd.Flags().Int("days", defaultCleanDays, | ||
| "remove indexes not used in the last N days (0 removes every index that is not currently being written)") | ||
| } | ||
|
|
||
| var cleanCmd = &cobra.Command{ | ||
| Use: "clean", | ||
| Short: "Remove unused or orphaned lumen indexes", | ||
| Long: fmt.Sprintf(`Deletes unused lumen index databases under ~/.local/share/lumen/. | ||
|
|
||
| An index is removed when it has not been opened for --days days (default %d), | ||
| or when the project it was built for no longer exists — indexes are keyed by | ||
| project path, embedding model, and index version, so renamed projects, deleted | ||
| checkouts, and abandoned models leave behind data that is never read again. | ||
|
|
||
| Indexes written by older binaries that never recorded an access time fall back | ||
| to their last indexing time; those without any usable timestamp are removed. | ||
|
|
||
| Use "lumen clean --days 0" to drop every cached index on this host, and | ||
| "lumen index --force <project-path>" to rebuild a single project from scratch. | ||
|
|
||
| Indexes with an indexer currently running are always kept.`, defaultCleanDays), | ||
| Args: cobra.NoArgs, | ||
| RunE: runClean, | ||
| } | ||
|
|
||
| func runClean(cmd *cobra.Command, _ []string) error { | ||
| days, err := cmd.Flags().GetInt("days") | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if days < 0 { | ||
| return fmt.Errorf("--days must not be negative, got %d", days) | ||
| } | ||
| dataDir := filepath.Join(config.XDGDataDir(), "lumen") | ||
| return cleanIndexes(cmd.ErrOrStderr(), cmd.OutOrStdout(), dataDir, days, time.Now()) | ||
| } | ||
|
|
||
| // cleanIndexes removes every stale index directory directly under dataDir, | ||
| // reporting each decision on stderr and a summary on stdout. now is injected so | ||
| // the age cutoff is testable. Failures to remove a single directory are | ||
| // reported and the sweep continues; the first such failure is returned once | ||
| // every directory has been considered. | ||
| func cleanIndexes(stderr, stdout io.Writer, dataDir string, days int, now time.Time) error { | ||
| entries, err := os.ReadDir(dataDir) | ||
| if err != nil { | ||
| if os.IsNotExist(err) { | ||
| _, _ = fmt.Fprintln(stderr, "No index data found — nothing to clean.") | ||
| return nil | ||
| } | ||
| return fmt.Errorf("read data dir: %w", err) | ||
| } | ||
|
|
||
| cutoff := now.Add(-time.Duration(days) * 24 * time.Hour) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Bound
A larger retention window must never delete more. 🐛 Proposed fix in runClean if days < 0 {
return fmt.Errorf("--days must not be negative, got %d", days)
}
+ const maxCleanDays = 100000 // keeps days*24h inside time.Duration's range
+ if days > maxCleanDays {
+ return fmt.Errorf("--days must not exceed %d, got %d", maxCleanDays, days)
+ }🤖 Prompt for AI Agents |
||
| removed, skipped := 0, 0 | ||
| var firstErr error | ||
|
|
||
| for _, entry := range entries { | ||
| // Only hash-named index directories are candidates; the shared | ||
| // debug.log lives in the same data dir. | ||
| if !entry.IsDir() { | ||
| continue | ||
| } | ||
| hashDir := filepath.Join(dataDir, entry.Name()) | ||
| dbPath := filepath.Join(hashDir, "index.db") | ||
|
|
||
| if indexlock.IsHeld(indexlock.LockPathForDB(dbPath)) { | ||
| _, _ = fmt.Fprintf(stderr, "Keeping %s: an indexer is currently running.\n", entry.Name()) | ||
| skipped++ | ||
| continue | ||
| } | ||
|
|
||
| stale, reason := isIndexStale(dbPath, days, cutoff) | ||
| if !stale { | ||
| skipped++ | ||
| continue | ||
| } | ||
| if err := os.RemoveAll(hashDir); err != nil { | ||
| _, _ = fmt.Fprintf(stderr, "Failed to remove %s: %v\n", hashDir, err) | ||
| if firstErr == nil { | ||
| firstErr = fmt.Errorf("remove %s: %w", hashDir, err) | ||
| } | ||
| skipped++ | ||
| continue | ||
| } | ||
|
Comment on lines
+107
to
+125
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Inspect the indexlock API: TryAcquire, IsHeld, Release, and where LockPathForDB places the lock file.
rg -nP --type=go -B 2 -A 25 'func (TryAcquire|IsHeld|LockPathForDB)\(|func \(.*Lock\) Release\(' internal/indexlockRepository: ory/lumen Length of output: 3971 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== cleanIndexes implementation =="
sed -n '80,135p' cmd/clean.go
echo
echo "== relevant indexlock tests =="
rg -n --type=go -B 4 -A 20 'TryAcquire|IsHeld|lock file|lock path|LockPathForDB|flock' cmd/clean_test.go internal/indexlock
echo
echo "== lock implementations =="
sed -n '1,90p' internal/indexlock/lock.goRepository: ory/lumen Length of output: 20577 Hold the index lock while cleaning the index directory.
Use 🤖 Prompt for AI Agents |
||
| _, _ = fmt.Fprintf(stderr, "Removed %s (%s).\n", entry.Name(), reason) | ||
| removed++ | ||
| } | ||
|
|
||
| _, _ = fmt.Fprintf(stdout, "Removed %d index director%s, skipped %d.\n", | ||
| removed, pluralY(removed), skipped) | ||
| return firstErr | ||
| } | ||
|
|
||
| // isIndexStale reports whether the index at dbPath is no longer worth keeping, | ||
| // along with a human-readable reason. The metadata read is read-only so it does | ||
| // not itself count as an access. | ||
| func isIndexStale(dbPath string, days int, cutoff time.Time) (bool, string) { | ||
| if days == 0 { | ||
| return true, "--days 0" | ||
| } | ||
|
|
||
| meta, err := store.ReadMetaAt(dbPath, "project_path", store.MetaLastAccessedAt, "last_indexed_at") | ||
| if err != nil { | ||
| // Missing, truncated, or non-lumen database: nothing here can be read | ||
| // again, so it is pure waste. | ||
| return true, "no readable index metadata" | ||
| } | ||
|
|
||
| projectPath := meta["project_path"] | ||
| if projectPath == "" { | ||
| return true, "no project path recorded" | ||
| } | ||
| info, statErr := os.Stat(projectPath) | ||
| switch { | ||
| case statErr == nil && !info.IsDir(): | ||
| return true, fmt.Sprintf("project path %s is not a directory", projectPath) | ||
| case os.IsNotExist(statErr): | ||
| return true, fmt.Sprintf("project %s no longer exists", projectPath) | ||
| } | ||
| // Any other stat error (e.g. an unreadable parent directory) is | ||
| // inconclusive — the project may well still be there, so fall through to | ||
| // the age check rather than deleting a live index. | ||
|
|
||
| if ts, ok := parseIndexTime(meta[store.MetaLastAccessedAt]); ok { | ||
| if ts.After(cutoff) { | ||
| return false, "" | ||
| } | ||
| return true, fmt.Sprintf("not accessed since %s", ts.Format(time.RFC3339)) | ||
| } | ||
| if ts, ok := parseIndexTime(meta["last_indexed_at"]); ok { | ||
| if ts.After(cutoff) { | ||
| return false, "" | ||
| } | ||
| return true, fmt.Sprintf("not indexed since %s", ts.Format(time.RFC3339)) | ||
| } | ||
| return true, "no usable access timestamp" | ||
| } | ||
|
|
||
| // parseIndexTime parses an RFC3339 metadata timestamp, reporting whether the | ||
| // value was present and well-formed. | ||
| func parseIndexTime(value string) (time.Time, bool) { | ||
| if value == "" { | ||
| return time.Time{}, false | ||
| } | ||
| ts, err := time.Parse(time.RFC3339, value) | ||
| if err != nil { | ||
| return time.Time{}, false | ||
| } | ||
| return ts, true | ||
| } | ||
|
|
||
| func pluralY(n int) string { | ||
| if n == 1 { | ||
| return "y" | ||
| } | ||
| return "ies" | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
lumen clean --days 0is not an unconditional full wipe.cmd/clean.go:84-133preserves indexes with active locks. Update both descriptions to state that the command removes all eligible indexes while keeping indexes used by active indexers.README.md#L401-L401: replace “remove every cached index” with the eligible-index behavior.skills/reindex/SKILL.md#L22-L23: replace “deletes every cached index” with the same active-lock exception.📍 Affects 2 files
README.md#L401-L401(this comment)skills/reindex/SKILL.md#L22-L23🤖 Prompt for AI Agents