on main help sign in
perf(db,config): lighter queries, indexes, cached reload/env work
0352d74iko
internal/config/load.go+14
@@ -969,12 +969,26 @@ func hasAWSCredentials(env env.Env) bool {
969 return false
969 return false
970}
970}
971
971
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+
972// migrateDisableNotifications migrates the deprecated disable_notifications
979// migrateDisableNotifications migrates the deprecated disable_notifications
973// field to notification_style. It checks both the user config (~/.config) and
980// field to notification_style. It checks both the user config (~/.config) and
974// data config (~/.local) files. If disable_notifications is true, it sets
981// data config (~/.local) files. If disable_notifications is true, it sets
975// notification_style to "disabled" in the data file. Regardless of value, it
982// notification_style to "disabled" in the data file. Regardless of value, it
976// removes disable_notifications from any file that contains it.
983// 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.
977func migrateDisableNotifications() {
987func migrateDisableNotifications() {
988+ migrateDisableNotificationsOnce.Do(migrateDisableNotificationsImpl)
989+}
990+
991+func migrateDisableNotificationsImpl() {
978 globalConfig := GlobalConfig()
992 globalConfig := GlobalConfig()
979 dataConfig := GlobalConfigData()
993 dataConfig := GlobalConfigData()
980
994
internal/config/resolve.go+11
@@ -3,6 +3,7 @@ package config
3import (
3import (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6+ "strings"
6 "time"
7 "time"
7
8
8 "github.com/charmbracelet/crush/internal/env"
9 "github.com/charmbracelet/crush/internal/env"
@@ -94,6 +95,16 @@ func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
94 return "", fmt.Errorf("invalid value format: %s", value)
95 return "", fmt.Errorf("invalid value format: %s", value)
95 }
96 }
96
97
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+
97 ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
108 ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
98 defer cancel()
109 defer cancel()
99
110
internal/db/db.go+13−3
@@ -11,10 +11,10 @@ import (
11)
11)
12
12
13type DBTX interface {
13type DBTX interface {
14− ExecContext(context.Context, string, ...any) (sql.Result, error)
14+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
15 PrepareContext(context.Context, string) (*sql.Stmt, error)
15 PrepareContext(context.Context, string) (*sql.Stmt, error)
16− QueryContext(context.Context, string, ...any) (*sql.Rows, error)
16+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
17− QueryRowContext(context.Context, string, ...any) *sql.Row
17+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
18}
18}
19
19
20func New(db DBTX) *Queries {
20func New(db DBTX) *Queries {
@@ -66,6 +66,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
66 if q.getLastSessionStmt, err = db.PrepareContext(ctx, getLastSession); err != nil {
66 if q.getLastSessionStmt, err = db.PrepareContext(ctx, getLastSession); err != nil {
67 return nil, fmt.Errorf("error preparing query GetLastSession: %w", err)
67 return nil, fmt.Errorf("error preparing query GetLastSession: %w", err)
68 }
68 }
69+ if q.getMaxFileVersionByPathStmt, err = db.PrepareContext(ctx, getMaxFileVersionByPath); err != nil {
70+ return nil, fmt.Errorf("error preparing query GetMaxFileVersionByPath: %w", err)
71+ }
69 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
72 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
70 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
73 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
71 }
74 }
@@ -210,6 +213,11 @@ func (q *Queries) Close() error {
210 err = fmt.Errorf("error closing getLastSessionStmt: %w", cerr)
213 err = fmt.Errorf("error closing getLastSessionStmt: %w", cerr)
211 }
214 }
212 }
215 }
216+ if q.getMaxFileVersionByPathStmt != nil {
217+ if cerr := q.getMaxFileVersionByPathStmt.Close(); cerr != nil {
218+ err = fmt.Errorf("error closing getMaxFileVersionByPathStmt: %w", cerr)
219+ }
220+ }
213 if q.getMessageStmt != nil {
221 if q.getMessageStmt != nil {
214 if cerr := q.getMessageStmt.Close(); cerr != nil {
222 if cerr := q.getMessageStmt.Close(); cerr != nil {
215 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
223 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
@@ -378,6 +386,7 @@ type Queries struct {
378 getFileReadStmt *sql.Stmt
386 getFileReadStmt *sql.Stmt
379 getHourDayHeatmapStmt *sql.Stmt
387 getHourDayHeatmapStmt *sql.Stmt
380 getLastSessionStmt *sql.Stmt
388 getLastSessionStmt *sql.Stmt
389+ getMaxFileVersionByPathStmt *sql.Stmt
381 getMessageStmt *sql.Stmt
390 getMessageStmt *sql.Stmt
382 getRecentActivityStmt *sql.Stmt
391 getRecentActivityStmt *sql.Stmt
383 getSessionByIDStmt *sql.Stmt
392 getSessionByIDStmt *sql.Stmt
@@ -421,6 +430,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
421 getFileReadStmt: q.getFileReadStmt,
430 getFileReadStmt: q.getFileReadStmt,
422 getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
431 getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
423 getLastSessionStmt: q.getLastSessionStmt,
432 getLastSessionStmt: q.getLastSessionStmt,
433+ getMaxFileVersionByPathStmt: q.getMaxFileVersionByPathStmt,
424 getMessageStmt: q.getMessageStmt,
434 getMessageStmt: q.getMessageStmt,
425 getRecentActivityStmt: q.getRecentActivityStmt,
435 getRecentActivityStmt: q.getRecentActivityStmt,
426 getSessionByIDStmt: q.getSessionByIDStmt,
436 getSessionByIDStmt: q.getSessionByIDStmt,
internal/db/files.sql.go+20−4
@@ -122,6 +122,21 @@ func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPath
122 return i, err
122 return i, err
123}
123}
124
124
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+
125const listFilesByPath = `-- name: ListFilesByPath :many
140const listFilesByPath = `-- name: ListFilesByPath :many
126SELECT id, session_id, path, content, version, created_at, updated_at
141SELECT id, session_id, path, content, version, created_at, updated_at
127FROM files
142FROM files
@@ -202,11 +217,12 @@ const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many
202SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
217SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
203FROM files f
218FROM files f
204INNER JOIN (
219INNER JOIN (
205− SELECT path, MAX(version) as max_version, MAX(created_at) as max_created_at
220+ SELECT f2.path, MAX(f2.version) as max_version, MAX(f2.created_at) as max_created_at
206− FROM files
221+ FROM files f2
222+ WHERE f2.session_id = ?1
207− GROUP BY path
223+ GROUP BY f2.path
208) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
224) 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
210ORDER BY f.path
226ORDER BY f.path
211`
227`
212
228
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 {
23 GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
23 GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
24 GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
24 GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
25 GetLastSession(ctx context.Context) (Session, error)
25 GetLastSession(ctx context.Context) (Session, error)
26+ GetMaxFileVersionByPath(ctx context.Context, path string) (int64, error)
26 GetMessage(ctx context.Context, id string) (Message, error)
27 GetMessage(ctx context.Context, id string) (Message, error)
27 GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
28 GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
28 GetSessionByID(ctx context.Context, id string) (Session, error)
29 GetSessionByID(ctx context.Context, id string) (Session, error)
internal/db/sql/files.sql+12−4
@@ -22,6 +22,13 @@ FROM files
22WHERE path = ?
22WHERE path = ?
23ORDER BY version DESC, created_at DESC;
23ORDER BY version DESC, created_at DESC;
24
24
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+
25-- name: CreateFile :one
32-- name: CreateFile :one
26INSERT INTO files (
33INSERT INTO files (
27 id,
34 id,
@@ -48,11 +55,12 @@ WHERE session_id = ?;
48SELECT f.*
55SELECT f.*
49FROM files f
56FROM files f
50INNER JOIN (
57INNER JOIN (
51− SELECT path, MAX(version) as max_version, MAX(created_at) as max_created_at
58+ SELECT f2.path, MAX(f2.version) as max_version, MAX(f2.created_at) as max_created_at
52− FROM files
59+ FROM files f2
60+ WHERE f2.session_id = @session_id
53− GROUP BY path
61+ GROUP BY f2.path
54) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
62) 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
56ORDER BY f.path;
64ORDER BY f.path;
57
65
58-- name: ListNewFiles :many
66-- name: ListNewFiles :many
internal/filetracker/service.go+19−2
@@ -7,6 +7,7 @@ import (
7 "log/slog"
7 "log/slog"
8 "os"
8 "os"
9 "path/filepath"
9 "path/filepath"
10+ "sync"
10 "time"
11 "time"
11
12
12 "github.com/charmbracelet/crush/internal/db"
13 "github.com/charmbracelet/crush/internal/db"
@@ -58,9 +59,25 @@ func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time
58 return time.Unix(readFile.ReadAt, 0)
59 return time.Unix(readFile.ReadAt, 0)
59}
60}
60
61
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+
61func relpath(path string) string {
78func relpath(path string) string {
62 path = filepath.Clean(path)
79 path = filepath.Clean(path)
63− basepath, err := os.Getwd()
80+ basepath, err := getwd()
64 if err != nil {
81 if err != nil {
65 slog.Warn("Error getting basepath", "error", err)
82 slog.Warn("Error getting basepath", "error", err)
66 return path
83 return path
@@ -80,7 +97,7 @@ func (s *service) ListReadFiles(ctx context.Context, sessionID string) ([]string
80 return nil, fmt.Errorf("listing read files: %w", err)
97 return nil, fmt.Errorf("listing read files: %w", err)
81 }
98 }
82
99
83− basepath, err := os.Getwd()
100+ basepath, err := getwd()
84 if err != nil {
101 if err != nil {
85 return nil, fmt.Errorf("getting working directory: %w", err)
102 return nil, fmt.Errorf("getting working directory: %w", err)
86 }
103 }
internal/history/file.go+13−10
@@ -3,6 +3,7 @@ package history
3import (
3import (
4 "context"
4 "context"
5 "database/sql"
5 "database/sql"
6+ "errors"
6 "fmt"
7 "fmt"
7 "strings"
8 "strings"
8
9
@@ -63,20 +64,22 @@ func (s *service) Create(ctx context.Context, sessionID, path, content string) (
63// number. If no previous versions exist for the path, it creates the initial
64// number. If no previous versions exist for the path, it creates the initial
64// version. The provided content is stored as the new version.
65// version. The provided content is stored as the new version.
65func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
66func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
66− // Get the latest version for this path
67+ // Get the latest version for this path. Only the version column is read
67− files, err := s.q.ListFilesByPath(ctx, path)
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)
68 if err != nil {
74 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+ }
69 return File{}, err
79 return File{}, err
70 }
80 }
71
81
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
80
83
81 return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
84 return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
82}
85}