tangled
alpha
login
or
join now
arabica.social
/
arabica
7
fork
atom
Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
7
fork
atom
overview
issues
pulls
pipelines
feat: improved fetch perf
Patrick Dewey
2 months ago
47a55264
275b2ca8
+139
-27
3 changed files
expand all
collapse all
unified
split
internal
atproto
store.go
store_test.go
handlers
handlers.go
+19
-9
internal/atproto/store.go
···
413
413
bean.RKey = components.RKey
414
414
}
415
415
416
416
-
// Resolve roaster reference if present
416
416
+
// Extract roaster rkey from reference (but don't fetch it - avoids N+1)
417
417
+
// The caller can link roasters using LinkBeansToRoasters after fetching both
417
418
if roasterRef, ok := rec.Value["roasterRef"].(string); ok && roasterRef != "" {
418
418
-
// Extract rkey from roaster ref
419
419
if components, err := ResolveATURI(roasterRef); err == nil {
420
420
bean.RoasterRKey = components.RKey
421
421
}
422
422
-
// Only try to resolve if it looks like a valid AT-URI
423
423
-
if len(roasterRef) > 10 && (roasterRef[:5] == "at://" || roasterRef[:4] == "did:") {
424
424
-
bean.Roaster, err = ResolveRoasterRef(ctx, s.client, roasterRef, s.sessionID)
425
425
-
if err != nil {
426
426
-
log.Warn().Err(err).Str("uri", rec.URI).Str("roaster_ref", roasterRef).Msg("Failed to resolve roaster reference")
427
427
-
}
428
428
-
}
429
422
}
430
423
431
424
beans = append(beans, bean)
432
425
}
433
426
434
427
return beans, nil
428
428
+
}
429
429
+
430
430
+
// LinkBeansToRoasters populates the Roaster field on beans using a pre-fetched roasters map
431
431
+
// This avoids N+1 queries when listing beans with their roasters
432
432
+
func LinkBeansToRoasters(beans []*models.Bean, roasters []*models.Roaster) {
433
433
+
// Build a map of rkey -> roaster for O(1) lookups
434
434
+
roasterMap := make(map[string]*models.Roaster, len(roasters))
435
435
+
for _, r := range roasters {
436
436
+
roasterMap[r.RKey] = r
437
437
+
}
438
438
+
439
439
+
// Link beans to their roasters
440
440
+
for _, bean := range beans {
441
441
+
if bean.RoasterRKey != "" {
442
442
+
bean.Roaster = roasterMap[bean.RoasterRKey]
443
443
+
}
444
444
+
}
435
445
}
436
446
437
447
func (s *AtprotoStore) UpdateBeanByRKey(rkey string, bean *models.UpdateBeanRequest) error {
+68
internal/atproto/store_test.go
···
1
1
+
package atproto
2
2
+
3
3
+
import (
4
4
+
"testing"
5
5
+
6
6
+
"arabica/internal/models"
7
7
+
)
8
8
+
9
9
+
func TestLinkBeansToRoasters(t *testing.T) {
10
10
+
t.Run("links beans to matching roasters", func(t *testing.T) {
11
11
+
roasters := []*models.Roaster{
12
12
+
{RKey: "roaster1", Name: "Roaster One"},
13
13
+
{RKey: "roaster2", Name: "Roaster Two"},
14
14
+
{RKey: "roaster3", Name: "Roaster Three"},
15
15
+
}
16
16
+
17
17
+
beans := []*models.Bean{
18
18
+
{RKey: "bean1", Name: "Bean One", RoasterRKey: "roaster1"},
19
19
+
{RKey: "bean2", Name: "Bean Two", RoasterRKey: "roaster2"},
20
20
+
{RKey: "bean3", Name: "Bean Three", RoasterRKey: ""}, // No roaster
21
21
+
}
22
22
+
23
23
+
LinkBeansToRoasters(beans, roasters)
24
24
+
25
25
+
// Bean 1 should be linked to Roaster One
26
26
+
if beans[0].Roaster == nil {
27
27
+
t.Error("Bean 1 should have roaster linked")
28
28
+
} else if beans[0].Roaster.Name != "Roaster One" {
29
29
+
t.Errorf("Bean 1 roaster = %q, want %q", beans[0].Roaster.Name, "Roaster One")
30
30
+
}
31
31
+
32
32
+
// Bean 2 should be linked to Roaster Two
33
33
+
if beans[1].Roaster == nil {
34
34
+
t.Error("Bean 2 should have roaster linked")
35
35
+
} else if beans[1].Roaster.Name != "Roaster Two" {
36
36
+
t.Errorf("Bean 2 roaster = %q, want %q", beans[1].Roaster.Name, "Roaster Two")
37
37
+
}
38
38
+
39
39
+
// Bean 3 should have no roaster
40
40
+
if beans[2].Roaster != nil {
41
41
+
t.Error("Bean 3 should have no roaster linked")
42
42
+
}
43
43
+
})
44
44
+
45
45
+
t.Run("handles missing roaster gracefully", func(t *testing.T) {
46
46
+
roasters := []*models.Roaster{
47
47
+
{RKey: "roaster1", Name: "Roaster One"},
48
48
+
}
49
49
+
50
50
+
beans := []*models.Bean{
51
51
+
{RKey: "bean1", Name: "Bean One", RoasterRKey: "nonexistent"},
52
52
+
}
53
53
+
54
54
+
// Should not panic
55
55
+
LinkBeansToRoasters(beans, roasters)
56
56
+
57
57
+
// Bean should have nil roaster since reference doesn't match
58
58
+
if beans[0].Roaster != nil {
59
59
+
t.Error("Bean with nonexistent roaster ref should have nil Roaster")
60
60
+
}
61
61
+
})
62
62
+
63
63
+
t.Run("handles empty slices", func(t *testing.T) {
64
64
+
// Should not panic with empty inputs
65
65
+
LinkBeansToRoasters(nil, nil)
66
66
+
LinkBeansToRoasters([]*models.Bean{}, []*models.Roaster{})
67
67
+
})
68
68
+
}
+52
-18
internal/handlers/handlers.go
···
436
436
437
437
didStr, _ := atproto.GetAuthenticatedDID(r.Context())
438
438
439
439
-
beans, err := store.ListBeans()
440
440
-
if err != nil {
441
441
-
http.Error(w, err.Error(), http.StatusInternalServerError)
442
442
-
return
439
439
+
// Fetch all collections in parallel for better performance
440
440
+
type result struct {
441
441
+
beans []*models.Bean
442
442
+
roasters []*models.Roaster
443
443
+
grinders []*models.Grinder
444
444
+
brewers []*models.Brewer
445
445
+
err error
446
446
+
which string
443
447
}
444
448
445
445
-
roasters, err := store.ListRoasters()
446
446
-
if err != nil {
447
447
-
http.Error(w, err.Error(), http.StatusInternalServerError)
448
448
-
return
449
449
-
}
449
449
+
results := make(chan result, 4)
450
450
451
451
-
grinders, err := store.ListGrinders()
452
452
-
if err != nil {
453
453
-
http.Error(w, err.Error(), http.StatusInternalServerError)
454
454
-
return
455
455
-
}
451
451
+
// Launch parallel fetches
452
452
+
go func() {
453
453
+
beans, err := store.ListBeans()
454
454
+
results <- result{beans: beans, err: err, which: "beans"}
455
455
+
}()
456
456
+
go func() {
457
457
+
roasters, err := store.ListRoasters()
458
458
+
results <- result{roasters: roasters, err: err, which: "roasters"}
459
459
+
}()
460
460
+
go func() {
461
461
+
grinders, err := store.ListGrinders()
462
462
+
results <- result{grinders: grinders, err: err, which: "grinders"}
463
463
+
}()
464
464
+
go func() {
465
465
+
brewers, err := store.ListBrewers()
466
466
+
results <- result{brewers: brewers, err: err, which: "brewers"}
467
467
+
}()
456
468
457
457
-
brewers, err := store.ListBrewers()
458
458
-
if err != nil {
459
459
-
http.Error(w, err.Error(), http.StatusInternalServerError)
460
460
-
return
469
469
+
// Collect results
470
470
+
var beans []*models.Bean
471
471
+
var roasters []*models.Roaster
472
472
+
var grinders []*models.Grinder
473
473
+
var brewers []*models.Brewer
474
474
+
475
475
+
for i := 0; i < 4; i++ {
476
476
+
res := <-results
477
477
+
if res.err != nil {
478
478
+
http.Error(w, res.err.Error(), http.StatusInternalServerError)
479
479
+
return
480
480
+
}
481
481
+
switch res.which {
482
482
+
case "beans":
483
483
+
beans = res.beans
484
484
+
case "roasters":
485
485
+
roasters = res.roasters
486
486
+
case "grinders":
487
487
+
grinders = res.grinders
488
488
+
case "brewers":
489
489
+
brewers = res.brewers
490
490
+
}
461
491
}
492
492
+
493
493
+
// Link beans to their roasters using the pre-fetched roasters
494
494
+
// This avoids N+1 queries when using ATProto store
495
495
+
atproto.LinkBeansToRoasters(beans, roasters)
462
496
463
497
if err := templates.RenderManage(w, beans, roasters, grinders, brewers, authenticated, didStr); err != nil {
464
498
http.Error(w, err.Error(), http.StatusInternalServerError)