Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: improved fetch perf

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