···1111 "maps"
1212 "net/http"
1313 "strings"
1414+ "sync"
1415 "time"
15161617 "atcr.io/pkg/atproto"
···2223// It stores manifests in ATProto as records
2324type ManifestStore struct {
2425 ctx *RegistryContext // Context with user/hold info
2626+ mu sync.RWMutex // Protects lastFetchedHoldDID
2527 lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull)
2628 blobStore distribution.BlobStore // Blob store for fetching config during push
2729}
···6769 // Store the hold DID for subsequent blob requests during pull
6870 // Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format)
6971 // The routing repository will cache this for concurrent blob fetches
7272+ s.mu.Lock()
7073 if manifestRecord.HoldDID != "" {
7174 // New format: DID reference (preferred)
7275 s.lastFetchedHoldDID = manifestRecord.HoldDID
···7477 // Legacy format: URL reference - convert to DID
7578 s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
7679 }
8080+ s.mu.Unlock()
77817882 var ociManifest []byte
7983···232236// GetLastFetchedHoldDID returns the hold DID from the most recently fetched manifest
233237// This is used by the routing repository to cache the hold for blob requests
234238func (s *ManifestStore) GetLastFetchedHoldDID() string {
239239+ s.mu.RLock()
240240+ defer s.mu.RUnlock()
235241 return s.lastFetchedHoldDID
236242}
237243
+3-3
pkg/appview/storage/manifest_store_test.go
···669669670670 if tt.expectPullIncrement {
671671 // Check that IncrementPullCount was called
672672- if mockDB.pullCount == 0 {
672672+ if mockDB.getPullCount() == 0 {
673673 t.Error("Expected pull count to be incremented for GET request, but it wasn't")
674674 }
675675 } else {
676676 // Check that IncrementPullCount was NOT called
677677- if mockDB.pullCount > 0 {
678678- t.Errorf("Expected pull count NOT to be incremented for %s request, but it was (count=%d)", tt.httpMethod, mockDB.pullCount)
677677+ if mockDB.getPullCount() > 0 {
678678+ t.Errorf("Expected pull count NOT to be incremented for %s request, but it was (count=%d)", tt.httpMethod, mockDB.getPullCount())
679679 }
680680 }
681681 })
+11-3
pkg/appview/storage/profile_test.go
···219219 // Clear migration locks before each test
220220 migrationLocks = sync.Map{}
221221222222+ var mu sync.Mutex
222223 putRecordCalled := false
223224 var migrationRequest map[string]any
224225···232233233234 // PutRecord (migration)
234235 if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
236236+ mu.Lock()
235237 putRecordCalled = true
236238 json.NewDecoder(r.Body).Decode(&migrationRequest)
239239+ mu.Unlock()
237240 w.WriteHeader(http.StatusOK)
238241 w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
239242 return
···270273 // Give goroutine time to execute
271274 time.Sleep(50 * time.Millisecond)
272275273273- if !putRecordCalled {
276276+ mu.Lock()
277277+ called := putRecordCalled
278278+ request := migrationRequest
279279+ mu.Unlock()
280280+281281+ if !called {
274282 t.Error("Expected migration PutRecord to be called")
275283 }
276284277277- if migrationRequest != nil {
278278- recordData := migrationRequest["record"].(map[string]any)
285285+ if request != nil {
286286+ recordData := request["record"].(map[string]any)
279287 migratedHold := recordData["defaultHold"]
280288 if migratedHold != tt.expectedHoldDID {
281289 t.Errorf("Migrated defaultHold = %v, want %v", migratedHold, tt.expectedHoldDID)
+21-5
pkg/appview/storage/routing_repository.go
···77import (
88 "context"
99 "log/slog"
1010+ "sync"
1011 "time"
11121213 "github.com/distribution/distribution/v3"
···1718type RoutingRepository struct {
1819 distribution.Repository
1920 Ctx *RegistryContext // All context and services (exported for token updates)
2121+ mu sync.Mutex // Protects manifestStore and blobStore
2022 manifestStore *ManifestStore // Cached manifest store instance
2123 blobStore *ProxyBlobStore // Cached blob store instance
2224}
···31333234// Manifests returns the ATProto-backed manifest service
3335func (r *RoutingRepository) Manifests(ctx context.Context, options ...distribution.ManifestServiceOption) (distribution.ManifestService, error) {
3636+ r.mu.Lock()
3437 // Create or return cached manifest store
3538 if r.manifestStore == nil {
3639 // Ensure blob store is created first (needed for label extraction during push)
4040+ // Release lock while calling Blobs to avoid deadlock
4141+ r.mu.Unlock()
3742 blobStore := r.Blobs(ctx)
4343+ r.mu.Lock()
38443939- r.manifestStore = NewManifestStore(r.Ctx, blobStore)
4545+ // Double-check after reacquiring lock (another goroutine might have set it)
4646+ if r.manifestStore == nil {
4747+ r.manifestStore = NewManifestStore(r.Ctx, blobStore)
4848+ }
4049 }
5050+ manifestStore := r.manifestStore
5151+ r.mu.Unlock()
41524253 // After any manifest operation, cache the hold DID for blob fetches
4354 // We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete
4455 go func() {
4556 time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete
4646- if holdDID := r.manifestStore.GetLastFetchedHoldDID(); holdDID != "" {
5757+ if holdDID := manifestStore.GetLastFetchedHoldDID(); holdDID != "" {
4758 // Cache for 10 minutes - should cover typical pull operations
4859 GetGlobalHoldCache().Set(r.Ctx.DID, r.Ctx.Repository, holdDID, 10*time.Minute)
4960 slog.Debug("Cached hold DID", "component", "storage/routing", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", holdDID)
5061 }
5162 }()
52635353- return r.manifestStore, nil
6464+ return manifestStore, nil
5465}
55665667// Blobs returns a proxy blob store that routes to external hold service
5768// The registry (AppView) NEVER stores blobs locally - all blobs go through hold service
5869func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
7070+ r.mu.Lock()
5971 // Return cached blob store if available
6072 if r.blobStore != nil {
7373+ blobStore := r.blobStore
7474+ r.mu.Unlock()
6175 slog.Debug("Returning cached blob store", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository)
6262- return r.blobStore
7676+ return blobStore
6377 }
64786579 // For pull operations, check if we have a cached hold DID from a recent manifest fetch
···859986100 // Create and cache proxy blob store
87101 r.blobStore = NewProxyBlobStore(r.Ctx)
8888- return r.blobStore
102102+ blobStore := r.blobStore
103103+ r.mu.Unlock()
104104+ return blobStore
89105}
9010691107// Tags returns the tag service