on main help sign in
perf(db,config): lighter queries, indexes, cached reload/env work

- history.CreateVersion: read only the max version (new GetMaxFileVersionByPath) instead of loading every version's full content just to compute the next version number. - Add composite indexes idx_messages_session_created and idx_files_session_version_created to avoid in-memory sorts on session load; scope ListLatestSessionFiles' aggregation subquery to the session instead of the whole files table. - config: guard the one-shot disable_notifications migration with sync.Once so it stops re-reading both config files on every reload; add a literal fast-path to ResolveValue so values with no shell metacharacters skip materializing os.Environ(). - filetracker: cache the working dir instead of calling os.Getwd() on every read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

commit0352d743631e797eb5a686c65de7298ef6a63c54 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 03:18 (3 months ago) parentd88bfb9
9 files changed+115−23
internal/config/load.go+14 internal/config/resolve.go+11 internal/db/db.go+13−3 internal/db/files.sql.go+20−4 internal/db/migrations/20260701000000_add_composite_indexes.sql+12 internal/db/querier.go+1 internal/db/sql/files.sql+12−4 internal/filetracker/service.go+19−2 internal/history/file.go+13−10
internal/config/load.go+14
@@ -969,12 +969,26 @@ func hasAWSCredentials(env env.Env) bool {
969969 return false
970970}
971971
972+// migrateDisableNotificationsOnce guards migrateDisableNotifications so the
973+// one-shot migration for the removed disable_notifications field runs at most
974+// once per process. Without this, every config reload re-read both config
975+// files and re-ran gjson scans over them even though the migration is only
976+// meaningful on first run.
977+var migrateDisableNotificationsOnce sync.Once
978+
972979// migrateDisableNotifications migrates the deprecated disable_notifications
973980// field to notification_style. It checks both the user config (~/.config) and
974981// data config (~/.local) files. If disable_notifications is true, it sets
975982// notification_style to "disabled" in the data file. Regardless of value, it
976983// removes disable_notifications from any file that contains it.
984+//
985+// It is safe to call from both Load and the reload path: the actual work runs
986+// at most once per process via migrateDisableNotificationsOnce.
977987func migrateDisableNotifications() {
988+ migrateDisableNotificationsOnce.Do(migrateDisableNotificationsImpl)
989+}
990+
991+func migrateDisableNotificationsImpl() {
978992 globalConfig := GlobalConfig()
979993 dataConfig := GlobalConfigData()
980994
internal/config/resolve.go+11
@@ -3,6 +3,7 @@ package config
33import (
44 "context"
55 "fmt"
6+ "strings"
67 "time"
78
89 "github.com/charmbracelet/crush/internal/env"
@@ -94,6 +95,16 @@ func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
9495 return "", fmt.Errorf("invalid value format: %s", value)
9596 }
9697
98+ // Fast path: a value with no shell-expansion metacharacters expands to
99+ // itself, so we can skip materialising the process environment via
100+ // r.env.Env() (which copies all of os.Environ()) and the whole shell
101+ // round-trip. The metacharacter set mirrors shell.ExpandValue's own
102+ // fast-path check ($, backtick, backslash, and both quote styles); any
103+ // value lacking all of them is a literal and cannot expand.
104+ if !strings.ContainsAny(value, "$`\\'\"") {
105+ return value, nil
106+ }
107+
97108 ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
98109 defer cancel()
99110
internal/db/db.go+13−3
@@ -11,10 +11,10 @@ import (
1111)
1212
1313type DBTX interface {
14− ExecContext(context.Context, string, ...any) (sql.Result, error)
14+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
1515 PrepareContext(context.Context, string) (*sql.Stmt, error)
16− QueryContext(context.Context, string, ...any) (*sql.Rows, error)
17− QueryRowContext(context.Context, string, ...any) *sql.Row
16+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
17+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
1818}
1919
2020func New(db DBTX) *Queries {
@@ -66,6 +66,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
6666 if q.getLastSessionStmt, err = db.PrepareContext(ctx, getLastSession); err != nil {
6767 return nil, fmt.Errorf("error preparing query GetLastSession: %w", err)
6868 }
69+ if q.getMaxFileVersionByPathStmt, err = db.PrepareContext(ctx, getMaxFileVersionByPath); err != nil {
70+ return nil, fmt.Errorf("error preparing query GetMaxFileVersionByPath: %w", err)
71+ }
6972 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
7073 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
7174 }
@@ -210,6 +213,11 @@ func (q *Queries) Close() error {
210213 err = fmt.Errorf("error closing getLastSessionStmt: %w", cerr)
211214 }
212215 }
216+ if q.getMaxFileVersionByPathStmt != nil {
217+ if cerr := q.getMaxFileVersionByPathStmt.Close(); cerr != nil {
218+ err = fmt.Errorf("error closing getMaxFileVersionByPathStmt: %w", cerr)
219+ }
220+ }
213221 if q.getMessageStmt != nil {
214222 if cerr := q.getMessageStmt.Close(); cerr != nil {
215223 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
@@ -378,6 +386,7 @@ type Queries struct {
378386 getFileReadStmt *sql.Stmt
379387 getHourDayHeatmapStmt *sql.Stmt
380388 getLastSessionStmt *sql.Stmt
389+ getMaxFileVersionByPathStmt *sql.Stmt
381390 getMessageStmt *sql.Stmt
382391 getRecentActivityStmt *sql.Stmt
383392 getSessionByIDStmt *sql.Stmt
@@ -421,6 +430,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
421430 getFileReadStmt: q.getFileReadStmt,
422431 getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
423432 getLastSessionStmt: q.getLastSessionStmt,
433+ getMaxFileVersionByPathStmt: q.getMaxFileVersionByPathStmt,
424434 getMessageStmt: q.getMessageStmt,
425435 getRecentActivityStmt: q.getRecentActivityStmt,
426436 getSessionByIDStmt: q.getSessionByIDStmt,
internal/db/files.sql.go+20−4
@@ -122,6 +122,21 @@ func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPath
122122 return i, err
123123}
124124
125+const getMaxFileVersionByPath = `-- name: GetMaxFileVersionByPath :one
126+SELECT version
127+FROM files
128+WHERE path = ?
129+ORDER BY version DESC, created_at DESC
130+LIMIT 1
131+`
132+
133+func (q *Queries) GetMaxFileVersionByPath(ctx context.Context, path string) (int64, error) {
134+ row := q.queryRow(ctx, q.getMaxFileVersionByPathStmt, getMaxFileVersionByPath, path)
135+ var version int64
136+ err := row.Scan(&version)
137+ return version, err
138+}
139+
125140const listFilesByPath = `-- name: ListFilesByPath :many
126141SELECT id, session_id, path, content, version, created_at, updated_at
127142FROM files
@@ -202,11 +217,12 @@ const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many
202217SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
203218FROM files f
204219INNER JOIN (
205− SELECT path, MAX(version) as max_version, MAX(created_at) as max_created_at
206− FROM files
207− GROUP BY path
220+ SELECT f2.path, MAX(f2.version) as max_version, MAX(f2.created_at) as max_created_at
221+ FROM files f2
222+ WHERE f2.session_id = ?1
223+ GROUP BY f2.path
208224) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
209−WHERE f.session_id = ?
225+WHERE f.session_id = ?1
210226ORDER BY f.path
211227`
212228
internal/db/migrations/20260701000000_add_composite_indexes.sqlnew file+12
@@ -0,0 +1,12 @@
1+-- +goose Up
2+-- +goose StatementBegin
3+-- Add composite indexes to speed up per-session ordered lookups.
4+CREATE INDEX IF NOT EXISTS idx_messages_session_created ON messages (session_id, created_at);
5+CREATE INDEX IF NOT EXISTS idx_files_session_version_created ON files (session_id, version, created_at);
6+-- +goose StatementEnd
7+
8+-- +goose Down
9+-- +goose StatementBegin
10+DROP INDEX IF EXISTS idx_messages_session_created;
11+DROP INDEX IF EXISTS idx_files_session_version_created;
12+-- +goose StatementEnd
internal/db/querier.go+1
@@ -23,6 +23,7 @@ type Querier interface {
2323 GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
2424 GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
2525 GetLastSession(ctx context.Context) (Session, error)
26+ GetMaxFileVersionByPath(ctx context.Context, path string) (int64, error)
2627 GetMessage(ctx context.Context, id string) (Message, error)
2728 GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
2829 GetSessionByID(ctx context.Context, id string) (Session, error)
internal/db/sql/files.sql+12−4
@@ -22,6 +22,13 @@ FROM files
2222WHERE path = ?
2323ORDER BY version DESC, created_at DESC;
2424
25+-- name: GetMaxFileVersionByPath :one
26+SELECT version
27+FROM files
28+WHERE path = ?
29+ORDER BY version DESC, created_at DESC
30+LIMIT 1;
31+
2532-- name: CreateFile :one
2633INSERT INTO files (
2734 id,
@@ -48,11 +55,12 @@ WHERE session_id = ?;
4855SELECT f.*
4956FROM files f
5057INNER JOIN (
51− SELECT path, MAX(version) as max_version, MAX(created_at) as max_created_at
52− FROM files
53− GROUP BY path
58+ SELECT f2.path, MAX(f2.version) as max_version, MAX(f2.created_at) as max_created_at
59+ FROM files f2
60+ WHERE f2.session_id = @session_id
61+ GROUP BY f2.path
5462) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
55−WHERE f.session_id = ?
63+WHERE f.session_id = @session_id
5664ORDER BY f.path;
5765
5866-- name: ListNewFiles :many
internal/filetracker/service.go+19−2
@@ -7,6 +7,7 @@ import (
77 "log/slog"
88 "os"
99 "path/filepath"
10+ "sync"
1011 "time"
1112
1213 "github.com/charmbracelet/crush/internal/db"
@@ -58,9 +59,25 @@ func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time
5859 return time.Unix(readFile.ReadAt, 0)
5960}
6061
62+// workingDir caches the process working directory. The working directory
63+// cannot legitimately change during normal operation, so resolving it once
64+// avoids an os.Getwd() syscall on every RecordRead/LastReadTime call.
65+var (
66+ workingDirOnce sync.Once
67+ workingDir string
68+ workingDirErr error
69+)
70+
71+func getwd() (string, error) {
72+ workingDirOnce.Do(func() {
73+ workingDir, workingDirErr = os.Getwd()
74+ })
75+ return workingDir, workingDirErr
76+}
77+
6178func relpath(path string) string {
6279 path = filepath.Clean(path)
63− basepath, err := os.Getwd()
80+ basepath, err := getwd()
6481 if err != nil {
6582 slog.Warn("Error getting basepath", "error", err)
6683 return path
@@ -80,7 +97,7 @@ func (s *service) ListReadFiles(ctx context.Context, sessionID string) ([]string
8097 return nil, fmt.Errorf("listing read files: %w", err)
8198 }
8299
83− basepath, err := os.Getwd()
100+ basepath, err := getwd()
84101 if err != nil {
85102 return nil, fmt.Errorf("getting working directory: %w", err)
86103 }
internal/history/file.go+13−10
@@ -3,6 +3,7 @@ package history
33import (
44 "context"
55 "database/sql"
6+ "errors"
67 "fmt"
78 "strings"
89
@@ -63,20 +64,22 @@ func (s *service) Create(ctx context.Context, sessionID, path, content string) (
6364// number. If no previous versions exist for the path, it creates the initial
6465// version. The provided content is stored as the new version.
6566func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
66− // Get the latest version for this path
67− files, err := s.q.ListFilesByPath(ctx, path)
67+ // Get the latest version for this path. Only the version column is read
68+ // (via ORDER BY version DESC LIMIT 1) to avoid loading every version's
69+ // full content just to compute the max. Versions start at InitialVersion
70+ // (0), so MAX cannot distinguish "no rows" from "only the initial
71+ // version"; sql.ErrNoRows is the reliable signal for "no previous
72+ // version", in which case we create the initial version.
73+ maxVersion, err := s.q.GetMaxFileVersionByPath(ctx, path)
6874 if err != nil {
75+ if errors.Is(err, sql.ErrNoRows) {
76+ // No previous versions, create initial
77+ return s.Create(ctx, sessionID, path, content)
78+ }
6979 return File{}, err
7080 }
7181
72− if len(files) == 0 {
73− // No previous versions, create initial
74− return s.Create(ctx, sessionID, path, content)
75− }
76−
77− // Get the latest version
78− latestFile := files[0] // Files are ordered by version DESC, created_at DESC
79− nextVersion := latestFile.Version + 1
82+ nextVersion := maxVersion + 1
8083
8184 return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
8285}