nandi/gleanpublic Fork 0
7d7fc0d
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.

refactor: reduce memory usage, sqlite temp tables to disk, cap allocsUnverified

Julien Robert committed 2026-05-10T01:40:36+02:00 Browse files
7d7fc0d parent: d629aca
modified internal/atproto/auth.go +1 -1
@@ -149,7 +149,7 @@ func pruneProfileCache() {
149149 for _, k := range toDelete {
150150 profileCache.Delete(k)
151151 }
152- profileCacheSize.Store(int64(len(toDelete)))
152+ profileCacheSize.Add(-int64(len(toDelete)))
153153 }
154154
155155 func ResolveProfile(ctx context.Context, did string) Profile {
@@ -149,7 +149,7 @@ func pruneProfileCache() {
149 for _, k := range toDelete {149 for _, k := range toDelete {
150 profileCache.Delete(k)150 profileCache.Delete(k)
151 }151 }
152- profileCacheSize.Store(int64(len(toDelete)))152+ profileCacheSize.Add(-int64(len(toDelete)))
153 }153 }
154 154
155 func ResolveProfile(ctx context.Context, did string) Profile {155 func ResolveProfile(ctx context.Context, did string) Profile {
modified internal/atproto/jetstream.go +63 -7
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "log/slog"
88 "strings"
9+ "sync"
910 "time"
1011
1112 jsc "github.com/bluesky-social/jetstream/pkg/client"
@@ -35,14 +36,12 @@ type EventHandler func(ctx context.Context, event *Event) error
3536 type jetstreamScheduler struct {
3637 handler EventHandler
3738 logger *slog.Logger
38- cursorStore CursorStore
39+ cursorStore *BatchCursorStore
3940 }
4041
4142 func (s *jetstreamScheduler) AddWork(ctx context.Context, _ string, evt *models.Event) error {
4243 if evt.TimeUS > 0 {
43- if err := s.cursorStore.SaveCursor(ctx, evt.TimeUS); err != nil {
44- s.logger.Warn("failed to save cursor", "error", err)
45- }
44+ s.cursorStore.Record(ctx, evt.TimeUS)
4645 }
4746
4847 if evt.Kind != models.EventKindCommit || evt.Commit == nil {
@@ -81,15 +80,17 @@ type JetstreamConsumer struct {
8180 client *jsc.Client
8281 logger *slog.Logger
8382 sched *jetstreamScheduler
84- cursorStore CursorStore
83+ cursorStore *BatchCursorStore
8584 rewind time.Duration
8685 }
8786
8887 func NewJetstreamConsumer(jetstreamURL string, handler EventHandler, logger *slog.Logger, cursorStore CursorStore) *JetstreamConsumer {
88+ batchCursor := NewBatchCursorStore(cursorStore, 5*time.Second, logger)
89+
8990 sched := &jetstreamScheduler{
9091 handler: handler,
9192 logger: logger,
92- cursorStore: cursorStore,
93+ cursorStore: batchCursor,
9394 }
9495
9596 wsURL := jetstreamURL
@@ -130,12 +131,14 @@ func NewJetstreamConsumer(jetstreamURL string, handler EventHandler, logger *slo
130131 client: c,
131132 logger: logger,
132133 sched: sched,
133- cursorStore: cursorStore,
134+ cursorStore: batchCursor,
134135 rewind: rewind,
135136 }
136137 }
137138
138139 func (jc *JetstreamConsumer) Start(ctx context.Context) error {
140+ go jc.cursorStore.Run(ctx)
141+
139142 for {
140143 var cursorPtr *int64
141144 if jc.cursorStore != nil {
@@ -165,3 +168,56 @@ func (jc *JetstreamConsumer) Start(ctx context.Context) error {
165168 }
166169 }
167170 }
171+
172+type BatchCursorStore struct {
173+ store CursorStore
174+ mu sync.Mutex
175+ cursor int64
176+ dirty bool
177+ flush time.Duration
178+ logger *slog.Logger
179+}
180+
181+func NewBatchCursorStore(store CursorStore, flushInterval time.Duration, logger *slog.Logger) *BatchCursorStore {
182+ return &BatchCursorStore{store: store, flush: flushInterval, logger: logger}
183+}
184+
185+func (b *BatchCursorStore) LoadCursor(ctx context.Context) (*int64, error) {
186+ return b.store.LoadCursor(ctx)
187+}
188+
189+func (b *BatchCursorStore) Record(_ context.Context, cursor int64) {
190+ b.mu.Lock()
191+ b.cursor = cursor
192+ b.dirty = true
193+ b.mu.Unlock()
194+}
195+
196+func (b *BatchCursorStore) Run(ctx context.Context) {
197+ ticker := time.NewTicker(b.flush)
198+ defer ticker.Stop()
199+ for {
200+ select {
201+ case <-ctx.Done():
202+ b.flushNow(context.Background())
203+ return
204+ case <-ticker.C:
205+ b.flushNow(ctx)
206+ }
207+ }
208+}
209+
210+func (b *BatchCursorStore) flushNow(ctx context.Context) {
211+ b.mu.Lock()
212+ if !b.dirty {
213+ b.mu.Unlock()
214+ return
215+ }
216+ cursor := b.cursor
217+ b.dirty = false
218+ b.mu.Unlock()
219+
220+ if err := b.store.SaveCursor(ctx, cursor); err != nil {
221+ b.logger.Warn("failed to save cursor", "error", err)
222+ }
223+}
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "log/slog"7 "log/slog"
8 "strings"8 "strings"
9+ "sync"
9 "time"10 "time"
10 11
11 jsc "github.com/bluesky-social/jetstream/pkg/client"12 jsc "github.com/bluesky-social/jetstream/pkg/client"
@@ -35,14 +36,12 @@ type EventHandler func(ctx context.Context, event *Event) error
35 type jetstreamScheduler struct {36 type jetstreamScheduler struct {
36 handler EventHandler37 handler EventHandler
37 logger *slog.Logger38 logger *slog.Logger
38- cursorStore CursorStore39+ cursorStore *BatchCursorStore
39 }40 }
40 41
41 func (s *jetstreamScheduler) AddWork(ctx context.Context, _ string, evt *models.Event) error {42 func (s *jetstreamScheduler) AddWork(ctx context.Context, _ string, evt *models.Event) error {
42 if evt.TimeUS > 0 {43 if evt.TimeUS > 0 {
43- if err := s.cursorStore.SaveCursor(ctx, evt.TimeUS); err != nil {44+ s.cursorStore.Record(ctx, evt.TimeUS)
44- s.logger.Warn("failed to save cursor", "error", err)
45- }
46 }45 }
47 46
48 if evt.Kind != models.EventKindCommit || evt.Commit == nil {47 if evt.Kind != models.EventKindCommit || evt.Commit == nil {
@@ -81,15 +80,17 @@ type JetstreamConsumer struct {
81 client *jsc.Client80 client *jsc.Client
82 logger *slog.Logger81 logger *slog.Logger
83 sched *jetstreamScheduler82 sched *jetstreamScheduler
84- cursorStore CursorStore83+ cursorStore *BatchCursorStore
85 rewind time.Duration84 rewind time.Duration
86 }85 }
87 86
88 func NewJetstreamConsumer(jetstreamURL string, handler EventHandler, logger *slog.Logger, cursorStore CursorStore) *JetstreamConsumer {87 func NewJetstreamConsumer(jetstreamURL string, handler EventHandler, logger *slog.Logger, cursorStore CursorStore) *JetstreamConsumer {
88+ batchCursor := NewBatchCursorStore(cursorStore, 5*time.Second, logger)
89+
89 sched := &jetstreamScheduler{90 sched := &jetstreamScheduler{
90 handler: handler,91 handler: handler,
91 logger: logger,92 logger: logger,
92- cursorStore: cursorStore,93+ cursorStore: batchCursor,
93 }94 }
94 95
95 wsURL := jetstreamURL96 wsURL := jetstreamURL
@@ -130,12 +131,14 @@ func NewJetstreamConsumer(jetstreamURL string, handler EventHandler, logger *slo
130 client: c,131 client: c,
131 logger: logger,132 logger: logger,
132 sched: sched,133 sched: sched,
133- cursorStore: cursorStore,134+ cursorStore: batchCursor,
134 rewind: rewind,135 rewind: rewind,
135 }136 }
136 }137 }
137 138
138 func (jc *JetstreamConsumer) Start(ctx context.Context) error {139 func (jc *JetstreamConsumer) Start(ctx context.Context) error {
140+ go jc.cursorStore.Run(ctx)
141+
139 for {142 for {
140 var cursorPtr *int64143 var cursorPtr *int64
141 if jc.cursorStore != nil {144 if jc.cursorStore != nil {
@@ -165,3 +168,56 @@ func (jc *JetstreamConsumer) Start(ctx context.Context) error {
165 }168 }
166 }169 }
167 }170 }
171+
172+type BatchCursorStore struct {
173+ store CursorStore
174+ mu sync.Mutex
175+ cursor int64
176+ dirty bool
177+ flush time.Duration
178+ logger *slog.Logger
179+}
180+
181+func NewBatchCursorStore(store CursorStore, flushInterval time.Duration, logger *slog.Logger) *BatchCursorStore {
182+ return &BatchCursorStore{store: store, flush: flushInterval, logger: logger}
183+}
184+
185+func (b *BatchCursorStore) LoadCursor(ctx context.Context) (*int64, error) {
186+ return b.store.LoadCursor(ctx)
187+}
188+
189+func (b *BatchCursorStore) Record(_ context.Context, cursor int64) {
190+ b.mu.Lock()
191+ b.cursor = cursor
192+ b.dirty = true
193+ b.mu.Unlock()
194+}
195+
196+func (b *BatchCursorStore) Run(ctx context.Context) {
197+ ticker := time.NewTicker(b.flush)
198+ defer ticker.Stop()
199+ for {
200+ select {
201+ case <-ctx.Done():
202+ b.flushNow(context.Background())
203+ return
204+ case <-ticker.C:
205+ b.flushNow(ctx)
206+ }
207+ }
208+}
209+
210+func (b *BatchCursorStore) flushNow(ctx context.Context) {
211+ b.mu.Lock()
212+ if !b.dirty {
213+ b.mu.Unlock()
214+ return
215+ }
216+ cursor := b.cursor
217+ b.dirty = false
218+ b.mu.Unlock()
219+
220+ if err := b.store.SaveCursor(ctx, cursor); err != nil {
221+ b.logger.Warn("failed to save cursor", "error", err)
222+ }
223+}
modified internal/cluster/article.go +89 -83
@@ -52,50 +52,48 @@ func (e *Engine) ComputeArticleEmbeddings(ctx context.Context) error {
5252 return fmt.Errorf("clean stale article embeddings: %w", err)
5353 }
5454
55- rows, err := conn.QueryContext(ctx, `
56- SELECT a.id, COALESCE(a.title, '') || ' ' || COALESCE(a.summary, '') || ' ' || COALESCE(a.content, '')
57- FROM articles.articles a
58- WHERE (COALESCE(a.title, '') != '' OR COALESCE(a.summary, '') != '' OR COALESCE(a.content, '') != '')
59- AND a.id NOT IN (SELECT article_id FROM recs.article_embeddings)
60- ORDER BY a.id
61- `)
62- if err != nil {
63- return err
64- }
65-
66- type article struct {
67- id int64
68- text string
69- }
70- var batch []article
71- for rows.Next() {
72- var a article
73- if err := rows.Scan(&a.id, &a.text); err != nil {
74- rows.Close()
55+ totalComputed := 0
56+ for {
57+ rows, err := conn.QueryContext(ctx, `
58+ SELECT a.id, COALESCE(a.title, '') || ' ' || COALESCE(a.summary, '') || ' ' || COALESCE(a.content, '')
59+ FROM articles.articles a
60+ WHERE (COALESCE(a.title, '') != '' OR COALESCE(a.summary, '') != '' OR COALESCE(a.content, '') != '')
61+ AND a.id NOT IN (SELECT article_id FROM recs.article_embeddings)
62+ ORDER BY a.id
63+ LIMIT ?
64+ `, embedBatchSize)
65+ if err != nil {
7566 return err
7667 }
77- a.text = truncateForEmbed(a.text)
78- batch = append(batch, a)
79- }
80- rows.Close()
8168
82- if len(batch) == 0 {
83- e.logger.Info("article embeddings up to date")
84- return nil
85- }
69+ type article struct {
70+ id int64
71+ text string
72+ }
73+ var batch []article
74+ for rows.Next() {
75+ var a article
76+ if err := rows.Scan(&a.id, &a.text); err != nil {
77+ rows.Close()
78+ return err
79+ }
80+ a.text = truncateForEmbed(a.text)
81+ batch = append(batch, a)
82+ }
83+ rows.Close()
8684
87- for i := 0; i < len(batch); i += embedBatchSize {
88- end := min(i+embedBatchSize, len(batch))
89- sub := batch[i:end]
85+ if len(batch) == 0 {
86+ break
87+ }
9088
91- texts := make([]string, len(sub))
92- for j, a := range sub {
89+ texts := make([]string, len(batch))
90+ for j, a := range batch {
9391 texts[j] = a.text
9492 }
9593
9694 embeddings, err := e.embedder.Embed(ctx, texts, "Represent this news article for retrieving topically similar articles. Focus on the subjects, themes, and key entities discussed.")
9795 if err != nil {
98- return fmt.Errorf("embed batch %d: %w", i/embedBatchSize, err)
96+ return fmt.Errorf("embed batch starting at total %d: %w", totalComputed, err)
9997 }
10098
10199 tx, err := conn.BeginTx(ctx, nil)
@@ -111,7 +109,7 @@ func (e *Engine) ComputeArticleEmbeddings(ctx context.Context) error {
111109 }
112110 if _, err := tx.ExecContext(ctx,
113111 `INSERT OR IGNORE INTO recs.article_embeddings(article_id, embedding) VALUES (?, ?)`,
114- sub[j].id, blob,
112+ batch[j].id, blob,
115113 ); err != nil {
116114 return fmt.Errorf("insert embedding: %w", err)
117115 }
@@ -121,13 +119,18 @@ func (e *Engine) ComputeArticleEmbeddings(ctx context.Context) error {
121119 return err
122120 }
123121
122+ totalComputed += len(batch)
124123 e.logger.Info("article embeddings batch computed",
125- slog.Int("batch", i/embedBatchSize),
126- slog.Int("count", len(sub)),
124+ slog.Int("batch_total", totalComputed),
125+ slog.Int("count", len(batch)),
127126 )
128127 }
129128
130- e.logger.Info("article embeddings computed", slog.Int("total", len(batch)))
129+ if totalComputed == 0 {
130+ e.logger.Info("article embeddings up to date")
131+ } else {
132+ e.logger.Info("article embeddings computed", slog.Int("total", totalComputed))
133+ }
131134 return nil
132135 }
133136
@@ -268,56 +271,54 @@ func (e *Engine) ComputeFeedEmbeddings(ctx context.Context) error {
268271 return fmt.Errorf("clean stale feed embeddings: %w", err)
269272 }
270273
271- rows, err := conn.QueryContext(ctx, `
272- SELECT f.feed_url, COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')
273- FROM articles.feeds f
274- WHERE (COALESCE(f.title, '') != '' OR COALESCE(f.description, '') != '')
275- AND (
276- f.feed_url NOT IN (SELECT feed_url FROM recs.feed_embedding_meta)
277- OR EXISTS (
278- SELECT 1 FROM recs.feed_embedding_meta fm
279- WHERE fm.feed_url = f.feed_url
280- AND fm.source_text != COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')
274+ totalComputed := 0
275+ for {
276+ rows, err := conn.QueryContext(ctx, `
277+ SELECT f.feed_url, COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')
278+ FROM articles.feeds f
279+ WHERE (COALESCE(f.title, '') != '' OR COALESCE(f.description, '') != '')
280+ AND (
281+ f.feed_url NOT IN (SELECT feed_url FROM recs.feed_embedding_meta)
282+ OR EXISTS (
283+ SELECT 1 FROM recs.feed_embedding_meta fm
284+ WHERE fm.feed_url = f.feed_url
285+ AND fm.source_text != COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')
286+ )
281287 )
282- )
283- ORDER BY f.feed_url
284- `)
285- if err != nil {
286- return err
287- }
288-
289- type feed struct {
290- url string
291- text string
292- }
293- var batch []feed
294- for rows.Next() {
295- var f feed
296- if err := rows.Scan(&f.url, &f.text); err != nil {
297- rows.Close()
288+ ORDER BY f.feed_url
289+ LIMIT ?
290+ `, embedBatchSize)
291+ if err != nil {
298292 return err
299293 }
300- batch = append(batch, f)
301- }
302- rows.Close()
303294
304- if len(batch) == 0 {
305- e.logger.Info("feed embeddings up to date")
306- return nil
307- }
295+ type feed struct {
296+ url string
297+ text string
298+ }
299+ var batch []feed
300+ for rows.Next() {
301+ var f feed
302+ if err := rows.Scan(&f.url, &f.text); err != nil {
303+ rows.Close()
304+ return err
305+ }
306+ batch = append(batch, f)
307+ }
308+ rows.Close()
308309
309- for i := 0; i < len(batch); i += embedBatchSize {
310- end := min(i+embedBatchSize, len(batch))
311- sub := batch[i:end]
310+ if len(batch) == 0 {
311+ break
312+ }
312313
313- texts := make([]string, len(sub))
314- for j, f := range sub {
314+ texts := make([]string, len(batch))
315+ for j, f := range batch {
315316 texts[j] = f.text
316317 }
317318
318319 embeddings, err := e.embedder.Embed(ctx, texts, "Represent this RSS feed description for discovering feeds with similar editorial focus and topic coverage.")
319320 if err != nil {
320- return fmt.Errorf("embed feed batch %d: %w", i/embedBatchSize, err)
321+ return fmt.Errorf("embed feed batch starting at total %d: %w", totalComputed, err)
321322 }
322323
323324 tx, err := conn.BeginTx(ctx, nil)
@@ -332,19 +333,19 @@ func (e *Engine) ComputeFeedEmbeddings(ctx context.Context) error {
332333 return fmt.Errorf("serialize feed embedding: %w", err)
333334 }
334335 if _, err := tx.ExecContext(ctx,
335- `DELETE FROM recs.feed_embeddings WHERE feed_url = ?`, sub[j].url,
336+ `DELETE FROM recs.feed_embeddings WHERE feed_url = ?`, batch[j].url,
336337 ); err != nil {
337338 return fmt.Errorf("delete feed embedding: %w", err)
338339 }
339340 if _, err := tx.ExecContext(ctx,
340341 `INSERT INTO recs.feed_embeddings(feed_url, embedding) VALUES (?, ?)`,
341- sub[j].url, blob,
342+ batch[j].url, blob,
342343 ); err != nil {
343344 return fmt.Errorf("insert feed embedding: %w", err)
344345 }
345346 if _, err := tx.ExecContext(ctx,
346347 `INSERT OR REPLACE INTO recs.feed_embedding_meta(feed_url, source_text) VALUES (?, ?)`,
347- sub[j].url, sub[j].text,
348+ batch[j].url, batch[j].text,
348349 ); err != nil {
349350 return fmt.Errorf("insert feed embedding: %w", err)
350351 }
@@ -354,13 +355,18 @@ func (e *Engine) ComputeFeedEmbeddings(ctx context.Context) error {
354355 return err
355356 }
356357
358+ totalComputed += len(batch)
357359 e.logger.Info("feed embeddings batch computed",
358- slog.Int("batch", i/embedBatchSize),
359- slog.Int("count", len(sub)),
360+ slog.Int("batch_total", totalComputed),
361+ slog.Int("count", len(batch)),
360362 )
361363 }
362364
363- e.logger.Info("feed embeddings computed", slog.Int("total", len(batch)))
365+ if totalComputed == 0 {
366+ e.logger.Info("feed embeddings up to date")
367+ } else {
368+ e.logger.Info("feed embeddings computed", slog.Int("total", totalComputed))
369+ }
364370 return nil
365371 }
366372
@@ -52,50 +52,48 @@ func (e *Engine) ComputeArticleEmbeddings(ctx context.Context) error {
52 return fmt.Errorf("clean stale article embeddings: %w", err)52 return fmt.Errorf("clean stale article embeddings: %w", err)
53 }53 }
54 54
55- rows, err := conn.QueryContext(ctx, `55+ totalComputed := 0
56- SELECT a.id, COALESCE(a.title, '') || ' ' || COALESCE(a.summary, '') || ' ' || COALESCE(a.content, '')56+ for {
57- FROM articles.articles a57+ rows, err := conn.QueryContext(ctx, `
58- WHERE (COALESCE(a.title, '') != '' OR COALESCE(a.summary, '') != '' OR COALESCE(a.content, '') != '')58+ SELECT a.id, COALESCE(a.title, '') || ' ' || COALESCE(a.summary, '') || ' ' || COALESCE(a.content, '')
59- AND a.id NOT IN (SELECT article_id FROM recs.article_embeddings)59+ FROM articles.articles a
60- ORDER BY a.id60+ WHERE (COALESCE(a.title, '') != '' OR COALESCE(a.summary, '') != '' OR COALESCE(a.content, '') != '')
61- `)61+ AND a.id NOT IN (SELECT article_id FROM recs.article_embeddings)
62- if err != nil {62+ ORDER BY a.id
63- return err63+ LIMIT ?
64- }64+ `, embedBatchSize)
65-65+ if err != nil {
66- type article struct {
67- id int64
68- text string
69- }
70- var batch []article
71- for rows.Next() {
72- var a article
73- if err := rows.Scan(&a.id, &a.text); err != nil {
74- rows.Close()
75 return err66 return err
76 }67 }
77- a.text = truncateForEmbed(a.text)
78- batch = append(batch, a)
79- }
80- rows.Close()
81 68
82- if len(batch) == 0 {69+ type article struct {
83- e.logger.Info("article embeddings up to date")70+ id int64
84- return nil71+ text string
85- }72+ }
73+ var batch []article
74+ for rows.Next() {
75+ var a article
76+ if err := rows.Scan(&a.id, &a.text); err != nil {
77+ rows.Close()
78+ return err
79+ }
80+ a.text = truncateForEmbed(a.text)
81+ batch = append(batch, a)
82+ }
83+ rows.Close()
86 84
87- for i := 0; i < len(batch); i += embedBatchSize {85+ if len(batch) == 0 {
88- end := min(i+embedBatchSize, len(batch))86+ break
89- sub := batch[i:end]87+ }
90 88
91- texts := make([]string, len(sub))89+ texts := make([]string, len(batch))
92- for j, a := range sub {90+ for j, a := range batch {
93 texts[j] = a.text91 texts[j] = a.text
94 }92 }
95 93
96 embeddings, err := e.embedder.Embed(ctx, texts, "Represent this news article for retrieving topically similar articles. Focus on the subjects, themes, and key entities discussed.")94 embeddings, err := e.embedder.Embed(ctx, texts, "Represent this news article for retrieving topically similar articles. Focus on the subjects, themes, and key entities discussed.")
97 if err != nil {95 if err != nil {
98- return fmt.Errorf("embed batch %d: %w", i/embedBatchSize, err)96+ return fmt.Errorf("embed batch starting at total %d: %w", totalComputed, err)
99 }97 }
100 98
101 tx, err := conn.BeginTx(ctx, nil)99 tx, err := conn.BeginTx(ctx, nil)
@@ -111,7 +109,7 @@ func (e *Engine) ComputeArticleEmbeddings(ctx context.Context) error {
111 }109 }
112 if _, err := tx.ExecContext(ctx,110 if _, err := tx.ExecContext(ctx,
113 `INSERT OR IGNORE INTO recs.article_embeddings(article_id, embedding) VALUES (?, ?)`,111 `INSERT OR IGNORE INTO recs.article_embeddings(article_id, embedding) VALUES (?, ?)`,
114- sub[j].id, blob,112+ batch[j].id, blob,
115 ); err != nil {113 ); err != nil {
116 return fmt.Errorf("insert embedding: %w", err)114 return fmt.Errorf("insert embedding: %w", err)
117 }115 }
@@ -121,13 +119,18 @@ func (e *Engine) ComputeArticleEmbeddings(ctx context.Context) error {
121 return err119 return err
122 }120 }
123 121
122+ totalComputed += len(batch)
124 e.logger.Info("article embeddings batch computed",123 e.logger.Info("article embeddings batch computed",
125- slog.Int("batch", i/embedBatchSize),124+ slog.Int("batch_total", totalComputed),
126- slog.Int("count", len(sub)),125+ slog.Int("count", len(batch)),
127 )126 )
128 }127 }
129 128
130- e.logger.Info("article embeddings computed", slog.Int("total", len(batch)))129+ if totalComputed == 0 {
130+ e.logger.Info("article embeddings up to date")
131+ } else {
132+ e.logger.Info("article embeddings computed", slog.Int("total", totalComputed))
133+ }
131 return nil134 return nil
132 }135 }
133 136
@@ -268,56 +271,54 @@ func (e *Engine) ComputeFeedEmbeddings(ctx context.Context) error {
268 return fmt.Errorf("clean stale feed embeddings: %w", err)271 return fmt.Errorf("clean stale feed embeddings: %w", err)
269 }272 }
270 273
271- rows, err := conn.QueryContext(ctx, `274+ totalComputed := 0
272- SELECT f.feed_url, COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')275+ for {
273- FROM articles.feeds f276+ rows, err := conn.QueryContext(ctx, `
274- WHERE (COALESCE(f.title, '') != '' OR COALESCE(f.description, '') != '')277+ SELECT f.feed_url, COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')
275- AND (278+ FROM articles.feeds f
276- f.feed_url NOT IN (SELECT feed_url FROM recs.feed_embedding_meta)279+ WHERE (COALESCE(f.title, '') != '' OR COALESCE(f.description, '') != '')
277- OR EXISTS (280+ AND (
278- SELECT 1 FROM recs.feed_embedding_meta fm281+ f.feed_url NOT IN (SELECT feed_url FROM recs.feed_embedding_meta)
279- WHERE fm.feed_url = f.feed_url282+ OR EXISTS (
280- AND fm.source_text != COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')283+ SELECT 1 FROM recs.feed_embedding_meta fm
284+ WHERE fm.feed_url = f.feed_url
285+ AND fm.source_text != COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')
286+ )
281 )287 )
282- )288+ ORDER BY f.feed_url
283- ORDER BY f.feed_url289+ LIMIT ?
284- `)290+ `, embedBatchSize)
285- if err != nil {291+ if err != nil {
286- return err
287- }
288-
289- type feed struct {
290- url string
291- text string
292- }
293- var batch []feed
294- for rows.Next() {
295- var f feed
296- if err := rows.Scan(&f.url, &f.text); err != nil {
297- rows.Close()
298 return err292 return err
299 }293 }
300- batch = append(batch, f)
301- }
302- rows.Close()
303 294
304- if len(batch) == 0 {295+ type feed struct {
305- e.logger.Info("feed embeddings up to date")296+ url string
306- return nil297+ text string
307- }298+ }
299+ var batch []feed
300+ for rows.Next() {
301+ var f feed
302+ if err := rows.Scan(&f.url, &f.text); err != nil {
303+ rows.Close()
304+ return err
305+ }
306+ batch = append(batch, f)
307+ }
308+ rows.Close()
308 309
309- for i := 0; i < len(batch); i += embedBatchSize {310+ if len(batch) == 0 {
310- end := min(i+embedBatchSize, len(batch))311+ break
311- sub := batch[i:end]312+ }
312 313
313- texts := make([]string, len(sub))314+ texts := make([]string, len(batch))
314- for j, f := range sub {315+ for j, f := range batch {
315 texts[j] = f.text316 texts[j] = f.text
316 }317 }
317 318
318 embeddings, err := e.embedder.Embed(ctx, texts, "Represent this RSS feed description for discovering feeds with similar editorial focus and topic coverage.")319 embeddings, err := e.embedder.Embed(ctx, texts, "Represent this RSS feed description for discovering feeds with similar editorial focus and topic coverage.")
319 if err != nil {320 if err != nil {
320- return fmt.Errorf("embed feed batch %d: %w", i/embedBatchSize, err)321+ return fmt.Errorf("embed feed batch starting at total %d: %w", totalComputed, err)
321 }322 }
322 323
323 tx, err := conn.BeginTx(ctx, nil)324 tx, err := conn.BeginTx(ctx, nil)
@@ -332,19 +333,19 @@ func (e *Engine) ComputeFeedEmbeddings(ctx context.Context) error {
332 return fmt.Errorf("serialize feed embedding: %w", err)333 return fmt.Errorf("serialize feed embedding: %w", err)
333 }334 }
334 if _, err := tx.ExecContext(ctx,335 if _, err := tx.ExecContext(ctx,
335- `DELETE FROM recs.feed_embeddings WHERE feed_url = ?`, sub[j].url,336+ `DELETE FROM recs.feed_embeddings WHERE feed_url = ?`, batch[j].url,
336 ); err != nil {337 ); err != nil {
337 return fmt.Errorf("delete feed embedding: %w", err)338 return fmt.Errorf("delete feed embedding: %w", err)
338 }339 }
339 if _, err := tx.ExecContext(ctx,340 if _, err := tx.ExecContext(ctx,
340 `INSERT INTO recs.feed_embeddings(feed_url, embedding) VALUES (?, ?)`,341 `INSERT INTO recs.feed_embeddings(feed_url, embedding) VALUES (?, ?)`,
341- sub[j].url, blob,342+ batch[j].url, blob,
342 ); err != nil {343 ); err != nil {
343 return fmt.Errorf("insert feed embedding: %w", err)344 return fmt.Errorf("insert feed embedding: %w", err)
344 }345 }
345 if _, err := tx.ExecContext(ctx,346 if _, err := tx.ExecContext(ctx,
346 `INSERT OR REPLACE INTO recs.feed_embedding_meta(feed_url, source_text) VALUES (?, ?)`,347 `INSERT OR REPLACE INTO recs.feed_embedding_meta(feed_url, source_text) VALUES (?, ?)`,
347- sub[j].url, sub[j].text,348+ batch[j].url, batch[j].text,
348 ); err != nil {349 ); err != nil {
349 return fmt.Errorf("insert feed embedding: %w", err)350 return fmt.Errorf("insert feed embedding: %w", err)
350 }351 }
@@ -354,13 +355,18 @@ func (e *Engine) ComputeFeedEmbeddings(ctx context.Context) error {
354 return err355 return err
355 }356 }
356 357
358+ totalComputed += len(batch)
357 e.logger.Info("feed embeddings batch computed",359 e.logger.Info("feed embeddings batch computed",
358- slog.Int("batch", i/embedBatchSize),360+ slog.Int("batch_total", totalComputed),
359- slog.Int("count", len(sub)),361+ slog.Int("count", len(batch)),
360 )362 )
361 }363 }
362 364
363- e.logger.Info("feed embeddings computed", slog.Int("total", len(batch)))365+ if totalComputed == 0 {
366+ e.logger.Info("feed embeddings up to date")
367+ } else {
368+ e.logger.Info("feed embeddings computed", slog.Int("total", totalComputed))
369+ }
364 return nil370 return nil
365 }371 }
366 372
modified internal/cluster/jaccard.go +3 -3
@@ -155,7 +155,7 @@ func (e *Engine) computeEmbeddingSimilarity(ctx context.Context, tx *sql.Tx) err
155155 }
156156
157157 const knnLimit = 50
158- stmt, err := tx.PrepareContext(ctx, `
158+ knnStmt, err := tx.PrepareContext(ctx, `
159159 SELECT feed_url, distance
160160 FROM recs.feed_embeddings
161161 WHERE embedding MATCH ? AND k = ?
@@ -164,7 +164,7 @@ func (e *Engine) computeEmbeddingSimilarity(ctx context.Context, tx *sql.Tx) err
164164 if err != nil {
165165 return err
166166 }
167- defer stmt.Close()
167+ defer knnStmt.Close()
168168
169169 updateStmt, err := tx.PrepareContext(ctx,
170170 `UPDATE _feed_sim_staging SET jaccard = jaccard + ? WHERE feed_a = ? AND feed_b = ?`,
@@ -175,7 +175,7 @@ func (e *Engine) computeEmbeddingSimilarity(ctx context.Context, tx *sql.Tx) err
175175 defer updateStmt.Close()
176176
177177 for _, f := range feeds {
178- knnRows, err := stmt.QueryContext(ctx, f.vec, knnLimit)
178+ knnRows, err := knnStmt.QueryContext(ctx, f.vec, knnLimit)
179179 if err != nil {
180180 return err
181181 }
@@ -155,7 +155,7 @@ func (e *Engine) computeEmbeddingSimilarity(ctx context.Context, tx *sql.Tx) err
155 }155 }
156 156
157 const knnLimit = 50157 const knnLimit = 50
158- stmt, err := tx.PrepareContext(ctx, `158+ knnStmt, err := tx.PrepareContext(ctx, `
159 SELECT feed_url, distance159 SELECT feed_url, distance
160 FROM recs.feed_embeddings160 FROM recs.feed_embeddings
161 WHERE embedding MATCH ? AND k = ?161 WHERE embedding MATCH ? AND k = ?
@@ -164,7 +164,7 @@ func (e *Engine) computeEmbeddingSimilarity(ctx context.Context, tx *sql.Tx) err
164 if err != nil {164 if err != nil {
165 return err165 return err
166 }166 }
167- defer stmt.Close()167+ defer knnStmt.Close()
168 168
169 updateStmt, err := tx.PrepareContext(ctx,169 updateStmt, err := tx.PrepareContext(ctx,
170 `UPDATE _feed_sim_staging SET jaccard = jaccard + ? WHERE feed_a = ? AND feed_b = ?`,170 `UPDATE _feed_sim_staging SET jaccard = jaccard + ? WHERE feed_a = ? AND feed_b = ?`,
@@ -175,7 +175,7 @@ func (e *Engine) computeEmbeddingSimilarity(ctx context.Context, tx *sql.Tx) err
175 defer updateStmt.Close()175 defer updateStmt.Close()
176 176
177 for _, f := range feeds {177 for _, f := range feeds {
178- knnRows, err := stmt.QueryContext(ctx, f.vec, knnLimit)178+ knnRows, err := knnStmt.QueryContext(ctx, f.vec, knnLimit)
179 if err != nil {179 if err != nil {
180 return err180 return err
181 }181 }
modified internal/cluster/social.go +48 -36
@@ -6,6 +6,7 @@ import (
66 )
77
88 const maxFollowDepth = 3
9+const maxReachablePerUser = 10000
910
1011 func chunk[T any](s []T, size int) [][]T {
1112 var chunks [][]T
@@ -33,43 +34,10 @@ func (e *Engine) ComputeFollowDistancesData(ctx context.Context, sources []strin
3334 distances := make(map[pair]int)
3435
3536 for _, src := range sources {
36- frontier := []string{src}
37- reachable := map[string]int{src: 0}
38-
39- for depth := 0; depth < maxFollowDepth && len(frontier) > 0; depth++ {
40- var nextLevel []string
41- for _, batch := range chunk(frontier, 500) {
42- ph := make([]string, len(batch))
43- args := make([]any, len(batch))
44- for i, did := range batch {
45- ph[i] = "?"
46- args[i] = did
47- }
48-
49- rows, err := e.db.QueryContext(ctx,
50- fmt.Sprintf(`SELECT target_did FROM main.follows WHERE user_did IN (%s) AND user_did != target_did`, joinPh(ph)),
51- args...,
52- )
53- if err != nil {
54- return nil, err
55- }
56-
57- for rows.Next() {
58- var dst string
59- if err := rows.Scan(&dst); err != nil {
60- rows.Close()
61- return nil, err
62- }
63- if _, ok := reachable[dst]; !ok {
64- reachable[dst] = depth + 1
65- nextLevel = append(nextLevel, dst)
66- }
67- }
68- rows.Close()
69- }
70- frontier = nextLevel
37+ reachable, err := e.bfsReachable(ctx, src)
38+ if err != nil {
39+ return nil, err
7140 }
72-
7341 for other, d := range reachable {
7442 if d > 0 {
7543 distances[pair{src, other}] = d
@@ -84,6 +52,50 @@ func (e *Engine) ComputeFollowDistancesData(ctx context.Context, sources []strin
8452 return result, nil
8553 }
8654
55+func (e *Engine) bfsReachable(ctx context.Context, src string) (map[string]int, error) {
56+ reachable := map[string]int{src: 0}
57+ frontier := []string{src}
58+
59+ for depth := 0; depth < maxFollowDepth && len(frontier) > 0; depth++ {
60+ var nextLevel []string
61+ for _, batch := range chunk(frontier, 500) {
62+ ph := make([]string, len(batch))
63+ args := make([]any, len(batch))
64+ for i, did := range batch {
65+ ph[i] = "?"
66+ args[i] = did
67+ }
68+
69+ rows, err := e.db.QueryContext(ctx,
70+ fmt.Sprintf(`SELECT target_did FROM main.follows WHERE user_did IN (%s) AND user_did != target_did`, joinPh(ph)),
71+ args...,
72+ )
73+ if err != nil {
74+ return reachable, err
75+ }
76+
77+ for rows.Next() {
78+ var dst string
79+ if err := rows.Scan(&dst); err != nil {
80+ rows.Close()
81+ return reachable, err
82+ }
83+ if _, ok := reachable[dst]; !ok {
84+ if len(reachable) >= maxReachablePerUser {
85+ rows.Close()
86+ return reachable, nil
87+ }
88+ reachable[dst] = depth + 1
89+ nextLevel = append(nextLevel, dst)
90+ }
91+ }
92+ rows.Close()
93+ }
94+ frontier = nextLevel
95+ }
96+ return reachable, nil
97+}
98+
8799 func (e *Engine) WriteFollowDistances(ctx context.Context, distances []followDistance) error {
88100 tx, err := e.db.BeginTx(ctx, nil)
89101 if err != nil {
@@ -6,6 +6,7 @@ import (
6 )6 )
7 7
8 const maxFollowDepth = 38 const maxFollowDepth = 3
9+const maxReachablePerUser = 10000
9 10
10 func chunk[T any](s []T, size int) [][]T {11 func chunk[T any](s []T, size int) [][]T {
11 var chunks [][]T12 var chunks [][]T
@@ -33,43 +34,10 @@ func (e *Engine) ComputeFollowDistancesData(ctx context.Context, sources []strin
33 distances := make(map[pair]int)34 distances := make(map[pair]int)
34 35
35 for _, src := range sources {36 for _, src := range sources {
36- frontier := []string{src}37+ reachable, err := e.bfsReachable(ctx, src)
37- reachable := map[string]int{src: 0}38+ if err != nil {
38-39+ return nil, err
39- for depth := 0; depth < maxFollowDepth && len(frontier) > 0; depth++ {
40- var nextLevel []string
41- for _, batch := range chunk(frontier, 500) {
42- ph := make([]string, len(batch))
43- args := make([]any, len(batch))
44- for i, did := range batch {
45- ph[i] = "?"
46- args[i] = did
47- }
48-
49- rows, err := e.db.QueryContext(ctx,
50- fmt.Sprintf(`SELECT target_did FROM main.follows WHERE user_did IN (%s) AND user_did != target_did`, joinPh(ph)),
51- args...,
52- )
53- if err != nil {
54- return nil, err
55- }
56-
57- for rows.Next() {
58- var dst string
59- if err := rows.Scan(&dst); err != nil {
60- rows.Close()
61- return nil, err
62- }
63- if _, ok := reachable[dst]; !ok {
64- reachable[dst] = depth + 1
65- nextLevel = append(nextLevel, dst)
66- }
67- }
68- rows.Close()
69- }
70- frontier = nextLevel
71 }40 }
72-
73 for other, d := range reachable {41 for other, d := range reachable {
74 if d > 0 {42 if d > 0 {
75 distances[pair{src, other}] = d43 distances[pair{src, other}] = d
@@ -84,6 +52,50 @@ func (e *Engine) ComputeFollowDistancesData(ctx context.Context, sources []strin
84 return result, nil52 return result, nil
85 }53 }
86 54
55+func (e *Engine) bfsReachable(ctx context.Context, src string) (map[string]int, error) {
56+ reachable := map[string]int{src: 0}
57+ frontier := []string{src}
58+
59+ for depth := 0; depth < maxFollowDepth && len(frontier) > 0; depth++ {
60+ var nextLevel []string
61+ for _, batch := range chunk(frontier, 500) {
62+ ph := make([]string, len(batch))
63+ args := make([]any, len(batch))
64+ for i, did := range batch {
65+ ph[i] = "?"
66+ args[i] = did
67+ }
68+
69+ rows, err := e.db.QueryContext(ctx,
70+ fmt.Sprintf(`SELECT target_did FROM main.follows WHERE user_did IN (%s) AND user_did != target_did`, joinPh(ph)),
71+ args...,
72+ )
73+ if err != nil {
74+ return reachable, err
75+ }
76+
77+ for rows.Next() {
78+ var dst string
79+ if err := rows.Scan(&dst); err != nil {
80+ rows.Close()
81+ return reachable, err
82+ }
83+ if _, ok := reachable[dst]; !ok {
84+ if len(reachable) >= maxReachablePerUser {
85+ rows.Close()
86+ return reachable, nil
87+ }
88+ reachable[dst] = depth + 1
89+ nextLevel = append(nextLevel, dst)
90+ }
91+ }
92+ rows.Close()
93+ }
94+ frontier = nextLevel
95+ }
96+ return reachable, nil
97+}
98+
87 func (e *Engine) WriteFollowDistances(ctx context.Context, distances []followDistance) error {99 func (e *Engine) WriteFollowDistances(ctx context.Context, distances []followDistance) error {
88 tx, err := e.db.BeginTx(ctx, nil)100 tx, err := e.db.BeginTx(ctx, nil)
89 if err != nil {101 if err != nil {
modified internal/db/db.go +3 -3
@@ -46,7 +46,7 @@ func Open(basePath string) (*Store, error) {
4646 `PRAGMA busy_timeout = 30000`,
4747 `PRAGMA synchronous = NORMAL`,
4848 `PRAGMA cache = shared`,
49- `PRAGMA temp_store = MEMORY`,
49+ `PRAGMA temp_store = FILE`,
5050 `PRAGMA mmap_size = 268435456`,
5151 } {
5252 if _, err := conn.Exec(p, nil); err != nil {
@@ -62,7 +62,7 @@ func Open(basePath string) (*Store, error) {
6262 `PRAGMA articles.busy_timeout = 30000`,
6363 `PRAGMA articles.synchronous = NORMAL`,
6464 `PRAGMA articles.cache = shared`,
65- `PRAGMA articles.temp_store = MEMORY`,
65+ `PRAGMA articles.temp_store = FILE`,
6666 `PRAGMA articles.mmap_size = 268435456`,
6767 } {
6868 if _, err := conn.Exec(p, nil); err != nil {
@@ -78,7 +78,7 @@ func Open(basePath string) (*Store, error) {
7878 `PRAGMA recs.busy_timeout = 30000`,
7979 `PRAGMA recs.synchronous = NORMAL`,
8080 `PRAGMA recs.cache = shared`,
81- `PRAGMA recs.temp_store = MEMORY`,
81+ `PRAGMA recs.temp_store = FILE`,
8282 `PRAGMA recs.mmap_size = 268435456`,
8383 } {
8484 if _, err := conn.Exec(p, nil); err != nil {
@@ -46,7 +46,7 @@ func Open(basePath string) (*Store, error) {
46 `PRAGMA busy_timeout = 30000`,46 `PRAGMA busy_timeout = 30000`,
47 `PRAGMA synchronous = NORMAL`,47 `PRAGMA synchronous = NORMAL`,
48 `PRAGMA cache = shared`,48 `PRAGMA cache = shared`,
49- `PRAGMA temp_store = MEMORY`,49+ `PRAGMA temp_store = FILE`,
50 `PRAGMA mmap_size = 268435456`,50 `PRAGMA mmap_size = 268435456`,
51 } {51 } {
52 if _, err := conn.Exec(p, nil); err != nil {52 if _, err := conn.Exec(p, nil); err != nil {
@@ -62,7 +62,7 @@ func Open(basePath string) (*Store, error) {
62 `PRAGMA articles.busy_timeout = 30000`,62 `PRAGMA articles.busy_timeout = 30000`,
63 `PRAGMA articles.synchronous = NORMAL`,63 `PRAGMA articles.synchronous = NORMAL`,
64 `PRAGMA articles.cache = shared`,64 `PRAGMA articles.cache = shared`,
65- `PRAGMA articles.temp_store = MEMORY`,65+ `PRAGMA articles.temp_store = FILE`,
66 `PRAGMA articles.mmap_size = 268435456`,66 `PRAGMA articles.mmap_size = 268435456`,
67 } {67 } {
68 if _, err := conn.Exec(p, nil); err != nil {68 if _, err := conn.Exec(p, nil); err != nil {
@@ -78,7 +78,7 @@ func Open(basePath string) (*Store, error) {
78 `PRAGMA recs.busy_timeout = 30000`,78 `PRAGMA recs.busy_timeout = 30000`,
79 `PRAGMA recs.synchronous = NORMAL`,79 `PRAGMA recs.synchronous = NORMAL`,
80 `PRAGMA recs.cache = shared`,80 `PRAGMA recs.cache = shared`,
81- `PRAGMA recs.temp_store = MEMORY`,81+ `PRAGMA recs.temp_store = FILE`,
82 `PRAGMA recs.mmap_size = 268435456`,82 `PRAGMA recs.mmap_size = 268435456`,
83 } {83 } {
84 if _, err := conn.Exec(p, nil); err != nil {84 if _, err := conn.Exec(p, nil); err != nil {