nandi/gleanpublic Fork 0
811355a
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add cron-based database maintenance and cleanup indexesUnverified

Julien Robert committed 2026-05-14T22:01:59+02:00 Browse files
811355a parent: b28f02b
modified docs/specs.md +4 -1
@@ -654,8 +654,9 @@ CREATE TABLE follows (
654654 PRIMARY KEY (user_did, target_did)
655655 );
656656
657-CREATE INDEX idx_follows_user ON follows(user_did);
658657 CREATE INDEX idx_follows_target ON follows(target_did);
658+CREATE INDEX idx_follows_uri ON follows(uri);
659+CREATE INDEX idx_follows_followed_at ON follows(followed_at);
659660 ```
660661
661662 ### 6.9 OAuth Storage (`<base>_users`)
@@ -804,6 +805,8 @@ A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`
804805 6. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)
805806 7. **Compute signal profiles**: Per-user category/tag/like summaries
806807 8. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action
808+9. **Prune old impressions**: Delete `recommendation_impressions` older than 90 days
809+10. **DB maintenance**: Run `PRAGMA incremental_vacuum` on all 3 databases (users, articles, recs) to reclaim freed pages. Incremental auto-vacuum is enabled via `PRAGMA auto_vacuum = INCREMENTAL` at connection time, so pages freed by impression pruning and other deletions are reclaimed each cycle.
807810
808811 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.
809812
@@ -654,8 +654,9 @@ CREATE TABLE follows (
654 PRIMARY KEY (user_did, target_did)654 PRIMARY KEY (user_did, target_did)
655 );655 );
656 656
657-CREATE INDEX idx_follows_user ON follows(user_did);
658 CREATE INDEX idx_follows_target ON follows(target_did);657 CREATE INDEX idx_follows_target ON follows(target_did);
658+CREATE INDEX idx_follows_uri ON follows(uri);
659+CREATE INDEX idx_follows_followed_at ON follows(followed_at);
659 ```660 ```
660 661
661 ### 6.9 OAuth Storage (`<base>_users`)662 ### 6.9 OAuth Storage (`<base>_users`)
@@ -804,6 +805,8 @@ A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`
804 6. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)805 6. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)
805 7. **Compute signal profiles**: Per-user category/tag/like summaries806 7. **Compute signal profiles**: Per-user category/tag/like summaries
806 8. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action807 8. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action
808+9. **Prune old impressions**: Delete `recommendation_impressions` older than 90 days
809+10. **DB maintenance**: Run `PRAGMA incremental_vacuum` on all 3 databases (users, articles, recs) to reclaim freed pages. Incremental auto-vacuum is enabled via `PRAGMA auto_vacuum = INCREMENTAL` at connection time, so pages freed by impression pruning and other deletions are reclaimed each cycle.
807 810
808 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.811 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.
809 812
modified internal/cluster/cron.go +13 -2
@@ -5,6 +5,7 @@ import (
55 "log/slog"
66 "time"
77
8+ "pkg.rbrt.fr/glean/internal/db"
89 "pkg.rbrt.fr/glean/internal/metrics"
910 )
1011
@@ -14,11 +15,17 @@ type Cron struct {
1415 engine *Engine
1516 interval time.Duration
1617 logger *slog.Logger
18+ dbs *db.Store
1719 }
1820
1921 // NewCron creates a new cron runner with the given engine and interval.
20-func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger) *Cron {
21- return &Cron{engine: engine, interval: interval, logger: logger}
22+func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger, dbs *db.Store) *Cron {
23+ return &Cron{
24+ engine: engine,
25+ interval: interval,
26+ logger: logger,
27+ dbs: dbs,
28+ }
2229 }
2330
2431 // Run starts the cron loop. It blocks until ctx is cancelled. Each tick runs
@@ -65,6 +72,10 @@ func (c *Cron) Run(ctx context.Context) error {
6572 c.engine.mu.Unlock()
6673 }
6774
75+ if err := c.dbs.RunMaintenance(ctx, 90); err != nil {
76+ c.logger.Error("db maintenance failed", "error", err)
77+ }
78+
6879 metrics.ClusterRuns.Inc()
6980 metrics.ClusterDuration.Observe(time.Since(start).Seconds())
7081 c.logger.Info("similarity computation complete", "next_run", c.interval)
@@ -5,6 +5,7 @@ import (
5 "log/slog"5 "log/slog"
6 "time"6 "time"
7 7
8+ "pkg.rbrt.fr/glean/internal/db"
8 "pkg.rbrt.fr/glean/internal/metrics"9 "pkg.rbrt.fr/glean/internal/metrics"
9 )10 )
10 11
@@ -14,11 +15,17 @@ type Cron struct {
14 engine *Engine15 engine *Engine
15 interval time.Duration16 interval time.Duration
16 logger *slog.Logger17 logger *slog.Logger
18+ dbs *db.Store
17 }19 }
18 20
19 // NewCron creates a new cron runner with the given engine and interval.21 // NewCron creates a new cron runner with the given engine and interval.
20-func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger) *Cron {22+func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger, dbs *db.Store) *Cron {
21- return &Cron{engine: engine, interval: interval, logger: logger}23+ return &Cron{
24+ engine: engine,
25+ interval: interval,
26+ logger: logger,
27+ dbs: dbs,
28+ }
22 }29 }
23 30
24 // Run starts the cron loop. It blocks until ctx is cancelled. Each tick runs31 // Run starts the cron loop. It blocks until ctx is cancelled. Each tick runs
@@ -65,6 +72,10 @@ func (c *Cron) Run(ctx context.Context) error {
65 c.engine.mu.Unlock()72 c.engine.mu.Unlock()
66 }73 }
67 74
75+ if err := c.dbs.RunMaintenance(ctx, 90); err != nil {
76+ c.logger.Error("db maintenance failed", "error", err)
77+ }
78+
68 metrics.ClusterRuns.Inc()79 metrics.ClusterRuns.Inc()
69 metrics.ClusterDuration.Observe(time.Since(start).Seconds())80 metrics.ClusterDuration.Observe(time.Since(start).Seconds())
70 c.logger.Info("similarity computation complete", "next_run", c.interval)81 c.logger.Info("similarity computation complete", "next_run", c.interval)
modified internal/db/db.go +15 -1
@@ -48,6 +48,7 @@ func Open(basePath string) (*Store, error) {
4848 `PRAGMA cache = shared`,
4949 `PRAGMA temp_store = FILE`,
5050 `PRAGMA mmap_size = 268435456`,
51+ `PRAGMA auto_vacuum = INCREMENTAL`,
5152 } {
5253 if _, err := conn.Exec(p, nil); err != nil {
5354 return err
@@ -125,6 +126,20 @@ func Open(basePath string) (*Store, error) {
125126 }, nil
126127 }
127128
129+func (s *Store) RunMaintenance(ctx context.Context, impressionMaxAgeDays int) error {
130+ cutoff := time.Now().AddDate(0, 0, -impressionMaxAgeDays).Format(time.RFC3339)
131+ if _, err := s.db.ExecContext(ctx, `DELETE FROM main.recommendation_impressions WHERE first_shown_at < ?`, cutoff); err != nil {
132+ return fmt.Errorf("prune impressions: %w", err)
133+ }
134+
135+ for _, schema := range []string{"main", "articles", "recs"} {
136+ if _, err := s.db.ExecContext(ctx, fmt.Sprintf("PRAGMA %s.incremental_vacuum", schema)); err != nil {
137+ return fmt.Errorf("incremental_vacuum %s: %w", schema, err)
138+ }
139+ }
140+ return nil
141+}
142+
128143 func (s *Store) Close() error {
129144 if s.db != nil {
130145 _ = s.db.Close()
@@ -235,7 +250,6 @@ var usersSchema = []string{
235250 PRIMARY KEY (account_did, session_id)
236251 )`,
237252
238- `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,
239253 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
240254 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
241255 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,
@@ -48,6 +48,7 @@ func Open(basePath string) (*Store, error) {
48 `PRAGMA cache = shared`,48 `PRAGMA cache = shared`,
49 `PRAGMA temp_store = FILE`,49 `PRAGMA temp_store = FILE`,
50 `PRAGMA mmap_size = 268435456`,50 `PRAGMA mmap_size = 268435456`,
51+ `PRAGMA auto_vacuum = INCREMENTAL`,
51 } {52 } {
52 if _, err := conn.Exec(p, nil); err != nil {53 if _, err := conn.Exec(p, nil); err != nil {
53 return err54 return err
@@ -125,6 +126,20 @@ func Open(basePath string) (*Store, error) {
125 }, nil126 }, nil
126 }127 }
127 128
129+func (s *Store) RunMaintenance(ctx context.Context, impressionMaxAgeDays int) error {
130+ cutoff := time.Now().AddDate(0, 0, -impressionMaxAgeDays).Format(time.RFC3339)
131+ if _, err := s.db.ExecContext(ctx, `DELETE FROM main.recommendation_impressions WHERE first_shown_at < ?`, cutoff); err != nil {
132+ return fmt.Errorf("prune impressions: %w", err)
133+ }
134+
135+ for _, schema := range []string{"main", "articles", "recs"} {
136+ if _, err := s.db.ExecContext(ctx, fmt.Sprintf("PRAGMA %s.incremental_vacuum", schema)); err != nil {
137+ return fmt.Errorf("incremental_vacuum %s: %w", schema, err)
138+ }
139+ }
140+ return nil
141+}
142+
128 func (s *Store) Close() error {143 func (s *Store) Close() error {
129 if s.db != nil {144 if s.db != nil {
130 _ = s.db.Close()145 _ = s.db.Close()
@@ -235,7 +250,6 @@ var usersSchema = []string{
235 PRIMARY KEY (account_did, session_id)250 PRIMARY KEY (account_did, session_id)
236 )`,251 )`,
237 252
238- `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,
239 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,253 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
240 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,254 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
241 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,255 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,
modified internal/db/migrations.go +19 -1
@@ -13,7 +13,7 @@ func init() {
1313 }
1414
1515 // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations).
16-const SchemaVersion = 5
16+const SchemaVersion = 6
1717
1818 type migration struct {
1919 id int
@@ -47,6 +47,11 @@ var migrations = []migration{
4747 name: "user_settings_expanded_view",
4848 run: migrateUserSettingsExpandedView,
4949 },
50+ {
51+ id: 6,
52+ name: "drop_redundant_follows_user_index",
53+ run: migrateDropFollowsUserIndex,
54+ },
5055 }
5156
5257 func runMigrations(db *DB) error {
@@ -248,3 +253,16 @@ func migrateUserSettingsExpandedView(db *DB) error {
248253 }
249254 return nil
250255 }
256+
257+func migrateDropFollowsUserIndex(db *DB) error {
258+ var name string
259+ err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_follows_user'").Scan(&name)
260+ if err == sql.ErrNoRows {
261+ return nil
262+ }
263+ if err != nil {
264+ return fmt.Errorf("check idx_follows_user: %w", err)
265+ }
266+ _, err = db.Exec("DROP INDEX IF EXISTS idx_follows_user")
267+ return err
268+}
@@ -13,7 +13,7 @@ func init() {
13 }13 }
14 14
15 // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations).15 // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations).
16-const SchemaVersion = 516+const SchemaVersion = 6
17 17
18 type migration struct {18 type migration struct {
19 id int19 id int
@@ -47,6 +47,11 @@ var migrations = []migration{
47 name: "user_settings_expanded_view",47 name: "user_settings_expanded_view",
48 run: migrateUserSettingsExpandedView,48 run: migrateUserSettingsExpandedView,
49 },49 },
50+ {
51+ id: 6,
52+ name: "drop_redundant_follows_user_index",
53+ run: migrateDropFollowsUserIndex,
54+ },
50 }55 }
51 56
52 func runMigrations(db *DB) error {57 func runMigrations(db *DB) error {
@@ -248,3 +253,16 @@ func migrateUserSettingsExpandedView(db *DB) error {
248 }253 }
249 return nil254 return nil
250 }255 }
256+
257+func migrateDropFollowsUserIndex(db *DB) error {
258+ var name string
259+ err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_follows_user'").Scan(&name)
260+ if err == sql.ErrNoRows {
261+ return nil
262+ }
263+ if err != nil {
264+ return fmt.Errorf("check idx_follows_user: %w", err)
265+ }
266+ _, err = db.Exec("DROP INDEX IF EXISTS idx_follows_user")
267+ return err
268+}
modified internal/feedback/feedback.go +13 -0
@@ -92,3 +92,16 @@ func (s *Service) IsFeedDismissed(ctx context.Context, userDID, feedURL string)
9292 `, userDID, feedURL).Scan(&count)
9393 return count > 0, err
9494 }
95+
96+func (s *Service) PruneOldImpressions(ctx context.Context, maxAgeDays int) (int64, error) {
97+ cutoff := time.Now().AddDate(0, 0, -maxAgeDays).Format(time.RFC3339)
98+
99+ res, err := s.db.ExecContext(ctx, `
100+ DELETE FROM main.recommendation_impressions
101+ WHERE first_shown_at < ?
102+ `, cutoff)
103+ if err != nil {
104+ return 0, err
105+ }
106+ return res.RowsAffected()
107+}
@@ -92,3 +92,16 @@ func (s *Service) IsFeedDismissed(ctx context.Context, userDID, feedURL string)
92 `, userDID, feedURL).Scan(&count)92 `, userDID, feedURL).Scan(&count)
93 return count > 0, err93 return count > 0, err
94 }94 }
95+
96+func (s *Service) PruneOldImpressions(ctx context.Context, maxAgeDays int) (int64, error) {
97+ cutoff := time.Now().AddDate(0, 0, -maxAgeDays).Format(time.RFC3339)
98+
99+ res, err := s.db.ExecContext(ctx, `
100+ DELETE FROM main.recommendation_impressions
101+ WHERE first_shown_at < ?
102+ `, cutoff)
103+ if err != nil {
104+ return 0, err
105+ }
106+ return res.RowsAffected()
107+}
modified main.go +1 -1
@@ -106,7 +106,7 @@ func main() {
106106 fetcher := feed.NewFetcher(siteFetcher)
107107 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))
108108
109- cron := cluster.NewCron(engine, *clusterInterval, logger)
109+ cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)
110110
111111 handler := atproto.NewStreamDBHandler(dbs.Articles, dbs.Users, logger)
112112 jetstream := atproto.NewJetstreamConsumer(*jetstreamURL, handler.Handle, logger, dbs.CursorStore())
@@ -106,7 +106,7 @@ func main() {
106 fetcher := feed.NewFetcher(siteFetcher)106 fetcher := feed.NewFetcher(siteFetcher)
107 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))107 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))
108 108
109- cron := cluster.NewCron(engine, *clusterInterval, logger)109+ cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)
110 110
111 handler := atproto.NewStreamDBHandler(dbs.Articles, dbs.Users, logger)111 handler := atproto.NewStreamDBHandler(dbs.Articles, dbs.Users, logger)
112 jetstream := atproto.NewJetstreamConsumer(*jetstreamURL, handler.Handle, logger, dbs.CursorStore())112 jetstream := atproto.NewJetstreamConsumer(*jetstreamURL, handler.Handle, logger, dbs.CursorStore())