mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-29 05:25:35 +00:00
appview: read the sailor profile from the local users row, not the PDS, per request
Hold discovery in the registry middleware called getRecord on the repository owner's PDS for every request under /v2/: every HEAD, POST, PATCH, PUT and GET. A 10-layer push was 40 or more PDS round trips, and it was the last per-request network call on the push path that had nothing to do with moving bytes. Only two profile fields are used there: the default hold and the auto-remove-untagged flag. The users row already caches the default hold, written by the Jetstream processor on every profile event and prefilled by the backfill, and the auth gate already reads it from there. This makes the row a faithful copy of what the registry needs and switches the middleware to it. The auto-remove flag gets a nullable users column. NULL means the value has never been learned; the processor writes 0 or 1 on every profile event and never NULL. On a request whose row is missing or still NULL, the middleware does one live fetch, uses it, and writes both fields back, including a 0 for a user with no profile at all, so the fallback runs at most once per user. A failed fetch writes nothing and uses the appview default for that request, so a network error is never cached. That single mechanism covers the minutes after a deploy while the startup backfill fills the column, a brand-new user, and a user the backfill has not reached. The processor also stops returning early on an empty default hold, which left a user who removed their custom hold pushing to it forever. Empty is now written through and means the appview default, matching what the auth gate already reads. Tests count PDS requests with a test server: a populated row makes none, a NULL row makes exactly one and then none, a missing profile is cached as known, and a failed fetch degrades without writing. The migration was applied to a fresh database and to one built from the previous schema. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
47a107058d
commit
7045e84c00
@@ -0,0 +1,281 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// The registry used to read the owner's sailor profile from their PDS on every
|
||||
// single /v2/ request, which made a ten-layer push forty-odd round trips to a
|
||||
// third-party server before a byte of image data moved. These tests count the
|
||||
// PDS requests rather than only checking the values that come back, because the
|
||||
// whole point of the change is the request that does not happen.
|
||||
|
||||
const prefsOwnerDID = "did:plc:prefsowner"
|
||||
const prefsOwnerHandle = "prefs.example.com"
|
||||
|
||||
// profilePDS starts a PDS whose getRecord handler is supplied by the caller,
|
||||
// and returns the server plus a counter of profile reads it served.
|
||||
func profilePDS(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *atomic.Int64) {
|
||||
t.Helper()
|
||||
var calls atomic.Int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
|
||||
calls.Add(1)
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &calls
|
||||
}
|
||||
|
||||
// servesProfile answers every profile read with the given record.
|
||||
func servesProfile(profile *atproto.SailorProfileRecord) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"value": profile})
|
||||
}
|
||||
}
|
||||
|
||||
func prefsTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := db.InitDB(":memory:", db.LibsqlConfig{})
|
||||
require.NoError(t, err, "open test database")
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
// seedUser inserts a users row. autoRemove nil leaves auto_remove_untagged
|
||||
// NULL, which is the "never learned" state.
|
||||
func seedUser(t *testing.T, database *sql.DB, holdDID string, autoRemove *bool) {
|
||||
t.Helper()
|
||||
var val any
|
||||
if autoRemove != nil {
|
||||
val = *autoRemove
|
||||
}
|
||||
_, err := database.Exec(`
|
||||
INSERT INTO users (did, handle, pds_endpoint, default_hold_did, auto_remove_untagged, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
prefsOwnerDID, prefsOwnerHandle, "https://pds.example.com", holdDID, val, time.Now())
|
||||
require.NoError(t, err, "seed user")
|
||||
}
|
||||
|
||||
// readCachedPrefs returns the raw row state, with auto_remove_untagged as a
|
||||
// tri-state so a written 0 is distinguishable from a still-NULL column.
|
||||
func readCachedPrefs(t *testing.T, database *sql.DB) (found bool, holdDID sql.NullString, autoRemove sql.NullBool) {
|
||||
t.Helper()
|
||||
err := database.QueryRow(
|
||||
`SELECT default_hold_did, auto_remove_untagged FROM users WHERE did = ?`,
|
||||
prefsOwnerDID).Scan(&holdDID, &autoRemove)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, holdDID, autoRemove
|
||||
}
|
||||
require.NoError(t, err, "read cached prefs")
|
||||
return true, holdDID, autoRemove
|
||||
}
|
||||
|
||||
// TestFindHoldDIDAndPrefs_CachedRowMakesNoPDSRequest is the whole point of the
|
||||
// change: when the Jetstream-fed row has both answers, the hot path must not
|
||||
// touch the owner's PDS at all.
|
||||
func TestFindHoldDIDAndPrefs_CachedRowMakesNoPDSRequest(t *testing.T) {
|
||||
database := prefsTestDB(t)
|
||||
known := true
|
||||
seedUser(t, database, "did:web:user.hold.io", &known)
|
||||
|
||||
pds, calls := profilePDS(t, servesProfile(atproto.NewSailorProfileRecord("did:web:should.not.be.read")))
|
||||
|
||||
resolver := &NamespaceResolver{
|
||||
defaultHoldDID: "did:web:default.atcr.io",
|
||||
userPrefs: db.NewHoldDIDDB(database),
|
||||
}
|
||||
|
||||
holdDID, prefs := resolver.findHoldDIDAndPrefs(context.Background(), prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
|
||||
assert.Equal(t, "did:web:user.hold.io", holdDID, "hold DID should come from the cached row")
|
||||
assert.True(t, prefs.AutoRemoveUntagged, "auto-remove should come from the cached row")
|
||||
assert.Equal(t, int64(0), calls.Load(), "a fully cached row must not read the PDS")
|
||||
}
|
||||
|
||||
// TestFindHoldDIDAndPrefs_CachedEmptyHoldUsesDefault pins the meaning of an
|
||||
// empty default_hold_did: not "unknown", but "use the operator's hold", the
|
||||
// same reading the auth gate's resolveHoldDID gives it.
|
||||
func TestFindHoldDIDAndPrefs_CachedEmptyHoldUsesDefault(t *testing.T) {
|
||||
database := prefsTestDB(t)
|
||||
known := false
|
||||
seedUser(t, database, "", &known)
|
||||
|
||||
pds, calls := profilePDS(t, servesProfile(atproto.NewSailorProfileRecord("did:web:should.not.be.read")))
|
||||
|
||||
resolver := &NamespaceResolver{
|
||||
defaultHoldDID: "did:web:default.atcr.io",
|
||||
userPrefs: db.NewHoldDIDDB(database),
|
||||
}
|
||||
|
||||
holdDID, prefs := resolver.findHoldDIDAndPrefs(context.Background(), prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
|
||||
assert.Equal(t, "did:web:default.atcr.io", holdDID, "an empty cached hold means the appview default")
|
||||
assert.False(t, prefs.AutoRemoveUntagged)
|
||||
assert.Equal(t, int64(0), calls.Load(), "a known-false auto-remove is still a known value")
|
||||
}
|
||||
|
||||
// TestFindHoldDIDAndPrefs_NullAutoRemoveFetchesOnce covers the window after a
|
||||
// deploy, while the backfill is still filling the new column: one live fetch,
|
||||
// written back, and never again.
|
||||
func TestFindHoldDIDAndPrefs_NullAutoRemoveFetchesOnce(t *testing.T) {
|
||||
database := prefsTestDB(t)
|
||||
seedUser(t, database, "did:web:stale.hold.io", nil) // auto_remove_untagged NULL
|
||||
|
||||
profile := atproto.NewSailorProfileRecord("did:web:user.hold.io")
|
||||
profile.AutoRemoveUntagged = true
|
||||
pds, calls := profilePDS(t, servesProfile(profile))
|
||||
|
||||
resolver := &NamespaceResolver{
|
||||
defaultHoldDID: "did:web:default.atcr.io",
|
||||
userPrefs: db.NewHoldDIDDB(database),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
holdDID, prefs := resolver.findHoldDIDAndPrefs(ctx, prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
assert.Equal(t, "did:web:user.hold.io", holdDID, "the live profile should serve this request")
|
||||
assert.True(t, prefs.AutoRemoveUntagged)
|
||||
assert.Equal(t, int64(1), calls.Load(), "a NULL auto-remove should cost exactly one fetch")
|
||||
|
||||
found, cachedHold, cachedAuto := readCachedPrefs(t, database)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "did:web:user.hold.io", cachedHold.String, "the fetch should be written back")
|
||||
require.True(t, cachedAuto.Valid, "auto_remove_untagged must no longer be NULL")
|
||||
assert.True(t, cachedAuto.Bool)
|
||||
|
||||
holdDID, prefs = resolver.findHoldDIDAndPrefs(ctx, prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
assert.Equal(t, "did:web:user.hold.io", holdDID)
|
||||
assert.True(t, prefs.AutoRemoveUntagged)
|
||||
assert.Equal(t, int64(1), calls.Load(), "the second request must be served from the row")
|
||||
}
|
||||
|
||||
// TestFindHoldDIDAndPrefs_MissingRowFetchesOnce covers a user the backfill has
|
||||
// never reached: no row at all, and the fallback still has to bound itself.
|
||||
func TestFindHoldDIDAndPrefs_MissingRowFetchesOnce(t *testing.T) {
|
||||
database := prefsTestDB(t)
|
||||
|
||||
profile := atproto.NewSailorProfileRecord("did:web:user.hold.io")
|
||||
profile.AutoRemoveUntagged = true
|
||||
pds, calls := profilePDS(t, servesProfile(profile))
|
||||
|
||||
resolver := &NamespaceResolver{
|
||||
defaultHoldDID: "did:web:default.atcr.io",
|
||||
userPrefs: db.NewHoldDIDDB(database),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
holdDID, prefs := resolver.findHoldDIDAndPrefs(ctx, prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
assert.Equal(t, "did:web:user.hold.io", holdDID)
|
||||
assert.True(t, prefs.AutoRemoveUntagged)
|
||||
assert.Equal(t, int64(1), calls.Load())
|
||||
|
||||
found, cachedHold, cachedAuto := readCachedPrefs(t, database)
|
||||
require.True(t, found, "the fallback should create the row it was missing")
|
||||
assert.Equal(t, "did:web:user.hold.io", cachedHold.String)
|
||||
require.True(t, cachedAuto.Valid)
|
||||
assert.True(t, cachedAuto.Bool)
|
||||
|
||||
_, _ = resolver.findHoldDIDAndPrefs(ctx, prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
assert.Equal(t, int64(1), calls.Load(), "a created row must stop the fallback repeating")
|
||||
}
|
||||
|
||||
// TestFindHoldDIDAndPrefs_MissingProfileCachedAsKnown covers the user who has
|
||||
// never written a sailor profile at all. A 404 is an answer, so it has to be
|
||||
// written down as 0 rather than left NULL, or that user pays for a PDS round
|
||||
// trip on every request forever.
|
||||
func TestFindHoldDIDAndPrefs_MissingProfileCachedAsKnown(t *testing.T) {
|
||||
database := prefsTestDB(t)
|
||||
|
||||
pds, calls := profilePDS(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
resolver := &NamespaceResolver{
|
||||
defaultHoldDID: "did:web:default.atcr.io",
|
||||
userPrefs: db.NewHoldDIDDB(database),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
holdDID, prefs := resolver.findHoldDIDAndPrefs(ctx, prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
assert.Equal(t, "did:web:default.atcr.io", holdDID, "no profile means the appview default")
|
||||
assert.False(t, prefs.AutoRemoveUntagged)
|
||||
assert.Equal(t, int64(1), calls.Load())
|
||||
|
||||
found, cachedHold, cachedAuto := readCachedPrefs(t, database)
|
||||
require.True(t, found, "a 404 profile should still leave a row behind")
|
||||
assert.Equal(t, "", cachedHold.String, "no custom hold: empty means the operator default")
|
||||
require.True(t, cachedAuto.Valid, "a 404 is a known answer, not an unknown one")
|
||||
assert.False(t, cachedAuto.Bool)
|
||||
|
||||
_, _ = resolver.findHoldDIDAndPrefs(ctx, prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
assert.Equal(t, int64(1), calls.Load(), "a user with no profile must not refetch every request")
|
||||
}
|
||||
|
||||
// TestFindHoldDIDAndPrefs_FailedFetchIsNotCached is the other side of the same
|
||||
// coin: a network failure is not an answer. Caching it would freeze a transient
|
||||
// blip into the user's routing for good.
|
||||
func TestFindHoldDIDAndPrefs_FailedFetchIsNotCached(t *testing.T) {
|
||||
database := prefsTestDB(t)
|
||||
|
||||
pds, calls := profilePDS(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
resolver := &NamespaceResolver{
|
||||
defaultHoldDID: "did:web:default.atcr.io",
|
||||
userPrefs: db.NewHoldDIDDB(database),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
holdDID, prefs := resolver.findHoldDIDAndPrefs(ctx, prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
assert.Equal(t, "did:web:default.atcr.io", holdDID, "a failed fetch should degrade to the default hold")
|
||||
assert.False(t, prefs.AutoRemoveUntagged)
|
||||
assert.Equal(t, int64(1), calls.Load())
|
||||
|
||||
found, _, _ := readCachedPrefs(t, database)
|
||||
assert.False(t, found, "a transient error must not be written down as the user's preference")
|
||||
|
||||
_, _ = resolver.findHoldDIDAndPrefs(ctx, prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
assert.Equal(t, int64(2), calls.Load(), "an uncached failure should be retried on the next request")
|
||||
}
|
||||
|
||||
// TestFindHoldDIDAndPrefs_FailedFetchLeavesExistingRowAlone is the same rule
|
||||
// applied to a user who already has a row: the blip must not clear the hold
|
||||
// they are actually using, nor stamp a guessed auto-remove over the unknown.
|
||||
func TestFindHoldDIDAndPrefs_FailedFetchLeavesExistingRowAlone(t *testing.T) {
|
||||
database := prefsTestDB(t)
|
||||
seedUser(t, database, "did:web:user.hold.io", nil)
|
||||
|
||||
pds, _ := profilePDS(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
resolver := &NamespaceResolver{
|
||||
defaultHoldDID: "did:web:default.atcr.io",
|
||||
userPrefs: db.NewHoldDIDDB(database),
|
||||
}
|
||||
|
||||
_, _ = resolver.findHoldDIDAndPrefs(context.Background(), prefsOwnerDID, prefsOwnerHandle, pds.URL)
|
||||
|
||||
found, cachedHold, cachedAuto := readCachedPrefs(t, database)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "did:web:user.hold.io", cachedHold.String, "a failed fetch must not clear the cached hold")
|
||||
assert.False(t, cachedAuto.Valid, "a failed fetch must leave the unknown unknown")
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
"github.com/distribution/reference"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/readme"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
@@ -176,6 +177,16 @@ type LabelChecker interface {
|
||||
IsTakenDown(did, repository string) (bool, error)
|
||||
}
|
||||
|
||||
// UserPrefsCache reads and writes the appview's local copy of the two sailor
|
||||
// profile fields the registry hot path needs. It is kept current by the
|
||||
// Jetstream processor and prefilled by the startup backfill; the middleware
|
||||
// only writes to it on the one-shot fallback for a user it has never seen.
|
||||
// Implemented by db.HoldDIDDB.
|
||||
type UserPrefsCache interface {
|
||||
GetUserHoldPrefs(did string) (db.UserHoldPrefs, error)
|
||||
CacheUserHoldPrefs(did, handle, pdsEndpoint, holdDID string, autoRemoveUntagged bool) error
|
||||
}
|
||||
|
||||
// Global variables for initialization only
|
||||
// These are set by main.go during startup and copied into NamespaceResolver instances.
|
||||
// After initialization, request handling uses the NamespaceResolver's instance fields.
|
||||
@@ -186,6 +197,7 @@ var (
|
||||
globalWebhookDispatcher storage.PushWebhookDispatcher
|
||||
globalManifestRefChecker storage.ManifestReferenceChecker
|
||||
globalLabelChecker LabelChecker
|
||||
globalUserPrefs UserPrefsCache
|
||||
)
|
||||
|
||||
// SetGlobalRefresher sets the OAuth refresher instance during initialization
|
||||
@@ -200,6 +212,13 @@ func SetGlobalDatabase(database storage.HoldDIDLookup) {
|
||||
globalDatabase = database
|
||||
}
|
||||
|
||||
// SetGlobalUserPrefs sets the cached sailor-profile preference store during
|
||||
// initialization. Must be called before the registry starts serving requests.
|
||||
// Leaving it nil is safe but costs a live profile fetch on every request.
|
||||
func SetGlobalUserPrefs(prefs UserPrefsCache) {
|
||||
globalUserPrefs = prefs
|
||||
}
|
||||
|
||||
// SetGlobalManifestRefChecker sets the manifest reference checker during initialization
|
||||
func SetGlobalManifestRefChecker(checker storage.ManifestReferenceChecker) {
|
||||
globalManifestRefChecker = checker
|
||||
@@ -248,6 +267,7 @@ type NamespaceResolver struct {
|
||||
manifestRefChecker storage.ManifestReferenceChecker // Manifest reference checker (copied from global on init)
|
||||
validationCache *validationCache // Request-level service token cache
|
||||
readmeFetcher *readme.Fetcher // README fetcher for repo pages
|
||||
userPrefs UserPrefsCache // Cached sailor profile preferences (copied from global on init)
|
||||
}
|
||||
|
||||
// initATProtoResolver initializes the name resolution middleware
|
||||
@@ -285,6 +305,7 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
|
||||
manifestRefChecker: globalManifestRefChecker,
|
||||
validationCache: newValidationCache(),
|
||||
readmeFetcher: readme.NewFetcher(),
|
||||
userPrefs: globalUserPrefs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -405,8 +426,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
}
|
||||
|
||||
// Query for hold DID - either user's hold or default hold service
|
||||
// Also returns the sailor profile so we can read preferences (e.g. AutoRemoveUntagged)
|
||||
holdDID, sailorProfile := nr.findHoldDIDAndProfile(ctx, did, pdsEndpoint)
|
||||
// Also returns the cached profile preferences (e.g. AutoRemoveUntagged)
|
||||
holdDID, prefs := nr.findHoldDIDAndPrefs(ctx, did, handle, pdsEndpoint)
|
||||
if holdDID == "" {
|
||||
// A fatal configuration error: the registry cannot function without a
|
||||
// hold service, so a 5xx is honest here. It still has to be a coded
|
||||
@@ -631,7 +652,7 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
PullerPDSEndpoint: pullerPDSEndpoint, // Puller's PDS for service token refresh
|
||||
HasPushScope: hasPushScope, // Whether JWT has push scope (for pull stats filtering)
|
||||
Anonymous: pullerDID == "", // No puller identity: hold decides via captain.Public
|
||||
AutoRemoveUntagged: sailorProfile != nil && sailorProfile.AutoRemoveUntagged,
|
||||
AutoRemoveUntagged: prefs.AutoRemoveUntagged,
|
||||
Database: nr.database,
|
||||
Authorizer: nr.authorizer,
|
||||
Refresher: nr.refresher,
|
||||
@@ -658,39 +679,109 @@ func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter {
|
||||
return nr.Namespace.BlobStatter()
|
||||
}
|
||||
|
||||
// findHoldDIDAndProfile determines which hold DID to use for blob storage and
|
||||
// returns the user's sailor profile (if available) for reading preferences like
|
||||
// AutoRemoveUntagged without an extra PDS call.
|
||||
// Priority order:
|
||||
// 1. User's sailor profile defaultHold (if set)
|
||||
// holdPrefs carries the only two sailor profile fields the registry hot path
|
||||
// reads. The full profile record is not needed here, and fetching it was the
|
||||
// last per-request PDS round trip on the push path that had nothing to do with
|
||||
// moving bytes.
|
||||
type holdPrefs struct {
|
||||
// AutoRemoveUntagged is whether a tag overwrite deletes the manifest that
|
||||
// lost its last tag.
|
||||
AutoRemoveUntagged bool
|
||||
}
|
||||
|
||||
// findHoldDIDAndPrefs determines which hold DID to use for blob storage and
|
||||
// returns the owner's cached profile preferences.
|
||||
//
|
||||
// The answer comes from the local `users` row, which Jetstream keeps current
|
||||
// (ProcessSailorProfile) and the startup backfill prefills. That row is read
|
||||
// once per request instead of doing a com.atproto.repo.getRecord against the
|
||||
// owner's PDS on every HEAD, POST, PATCH, PUT and GET under /v2/, which on a
|
||||
// ten-layer push was forty-odd round trips.
|
||||
//
|
||||
// Priority order is unchanged:
|
||||
// 1. The user's cached defaultHold (if set)
|
||||
// 2. AppView's default hold DID
|
||||
// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured
|
||||
func (nr *NamespaceResolver) findHoldDIDAndProfile(ctx context.Context, did, pdsEndpoint string) (string, *atproto.SailorProfileRecord) {
|
||||
// Create ATProto client (without auth - reading public records)
|
||||
//
|
||||
// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none
|
||||
// configured anywhere.
|
||||
func (nr *NamespaceResolver) findHoldDIDAndPrefs(ctx context.Context, did, handle, pdsEndpoint string) (string, holdPrefs) {
|
||||
if nr.userPrefs != nil {
|
||||
prefs, err := nr.userPrefs.GetUserHoldPrefs(did)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to read cached hold preferences, falling back to a live profile fetch",
|
||||
"component", "registry/middleware", "did", did, "error", err)
|
||||
} else if prefs.Found && prefs.AutoRemoveUntagged.Valid {
|
||||
// Both fields are known locally. The cached defaultHold was already
|
||||
// normalized to a DID by whoever wrote it, so no URL-to-DID
|
||||
// migration is needed on this path.
|
||||
return nr.applyTestModeFallback(ctx, prefs.DefaultHoldDID),
|
||||
holdPrefs{AutoRemoveUntagged: prefs.AutoRemoveUntagged.Bool}
|
||||
}
|
||||
}
|
||||
|
||||
// The row is missing, or auto_remove_untagged is still NULL ("never
|
||||
// learned"). Learn it from the PDS once and write it down.
|
||||
return nr.learnHoldPrefs(ctx, did, handle, pdsEndpoint)
|
||||
}
|
||||
|
||||
// learnHoldPrefs fetches the sailor profile live, exactly once per user, and
|
||||
// caches what it finds so no later request has to.
|
||||
//
|
||||
// This one mechanism covers every way the local cache can have nothing to say:
|
||||
// the minutes after a deploy while the startup backfill fills a new column, a
|
||||
// brand-new user with no row at all, and a user the backfill has not reached.
|
||||
func (nr *NamespaceResolver) learnHoldPrefs(ctx context.Context, did, handle, pdsEndpoint string) (string, holdPrefs) {
|
||||
// Unauthenticated client: the sailor profile is a public record.
|
||||
client := atproto.NewClient(pdsEndpoint, did, "")
|
||||
|
||||
// Check for sailor profile
|
||||
profile, err := storage.GetProfile(ctx, client)
|
||||
if err != nil {
|
||||
// Error reading profile (not a 404) - log and continue
|
||||
slog.Warn("Failed to read profile", "did", did, "error", err)
|
||||
// A network failure is not an answer. Serve this request from the
|
||||
// appview default and write nothing, so a transient error does not get
|
||||
// cached as the user's preference and silence the fallback forever.
|
||||
slog.Warn("Failed to read profile, using default hold for this request",
|
||||
"component", "registry/middleware", "did", did, "error", err)
|
||||
return nr.defaultHoldDID, holdPrefs{}
|
||||
}
|
||||
|
||||
if profile != nil && profile.DefaultHold != "" {
|
||||
// Profile exists with defaultHold set
|
||||
// In test mode, verify it's reachable before using it
|
||||
if nr.testMode {
|
||||
if nr.isHoldReachable(ctx, profile.DefaultHold) {
|
||||
return profile.DefaultHold, profile
|
||||
}
|
||||
slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", profile.DefaultHold)
|
||||
return nr.defaultHoldDID, profile
|
||||
// A missing profile (404) is an answer: no custom hold, no auto-remove.
|
||||
// It is cached like any other, so the fallback does not repeat for a user
|
||||
// who has never written a profile record.
|
||||
holdDID, autoRemove := "", false
|
||||
if profile != nil {
|
||||
holdDID = profile.DefaultHold
|
||||
autoRemove = profile.AutoRemoveUntagged
|
||||
}
|
||||
|
||||
slog.Debug("Sailor profile not cached locally, fetched live and caching",
|
||||
"component", "registry/middleware", "did", did,
|
||||
"default_hold", holdDID, "auto_remove_untagged", autoRemove,
|
||||
"profile_exists", profile != nil)
|
||||
|
||||
if nr.userPrefs != nil {
|
||||
if err := nr.userPrefs.CacheUserHoldPrefs(did, handle, pdsEndpoint, holdDID, autoRemove); err != nil {
|
||||
slog.Warn("Failed to cache hold preferences; the live fetch will repeat",
|
||||
"component", "registry/middleware", "did", did, "error", err)
|
||||
}
|
||||
return profile.DefaultHold, profile
|
||||
}
|
||||
|
||||
// No profile defaultHold - use AppView default
|
||||
return nr.defaultHoldDID, profile
|
||||
return nr.applyTestModeFallback(ctx, holdDID), holdPrefs{AutoRemoveUntagged: autoRemove}
|
||||
}
|
||||
|
||||
// applyTestModeFallback turns a user's chosen hold into the hold to actually
|
||||
// use. An empty choice means the appview default. In test mode a chosen hold
|
||||
// that is not answering also falls back, so a developer whose local hold is
|
||||
// down can still push.
|
||||
func (nr *NamespaceResolver) applyTestModeFallback(ctx context.Context, userHoldDID string) string {
|
||||
if userHoldDID == "" {
|
||||
return nr.defaultHoldDID
|
||||
}
|
||||
if nr.testMode && !nr.isHoldReachable(ctx, userHoldDID) {
|
||||
slog.Debug("User's defaultHold unreachable, falling back to default",
|
||||
"component", "registry/middleware/testmode", "default_hold", userHoldDID)
|
||||
return nr.defaultHoldDID
|
||||
}
|
||||
return userHoldDID
|
||||
}
|
||||
|
||||
// resolveSuccessor checks if a hold has declared a successor and returns it.
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestFindHoldDID_DefaultFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
holdDID, _ := resolver.findHoldDIDAndProfile(ctx, "did:plc:test123", mockPDS.URL)
|
||||
holdDID, _ := resolver.findHoldDIDAndPrefs(ctx, "did:plc:test123", "test.example.com", mockPDS.URL)
|
||||
|
||||
assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default hold DID")
|
||||
}
|
||||
@@ -191,7 +191,7 @@ func TestFindHoldDID_SailorProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
holdDID, _ := resolver.findHoldDIDAndProfile(ctx, "did:plc:test123", mockPDS.URL)
|
||||
holdDID, _ := resolver.findHoldDIDAndPrefs(ctx, "did:plc:test123", "test.example.com", mockPDS.URL)
|
||||
|
||||
assert.Equal(t, "did:web:user.hold.io", holdDID, "should use sailor profile's defaultHold")
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func TestFindHoldDID_Priority(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
holdDID, _ := resolver.findHoldDIDAndProfile(ctx, "did:plc:test123", mockPDS.URL)
|
||||
holdDID, _ := resolver.findHoldDIDAndPrefs(ctx, "did:plc:test123", "test.example.com", mockPDS.URL)
|
||||
|
||||
// Profile should take priority over hold records and default
|
||||
assert.Equal(t, "did:web:profile.hold.io", holdDID, "should prioritize sailor profile over hold records")
|
||||
@@ -247,7 +247,7 @@ func TestFindHoldDID_TestModeFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
holdDID, _ := resolver.findHoldDIDAndProfile(ctx, "did:plc:test123", mockPDS.URL)
|
||||
holdDID, _ := resolver.findHoldDIDAndPrefs(ctx, "did:plc:test123", "test.example.com", mockPDS.URL)
|
||||
|
||||
// In test mode with unreachable hold, should fall back to default
|
||||
assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default in test mode when hold unreachable")
|
||||
|
||||
Reference in New Issue
Block a user