Files
at-container-registry/pkg/hold/admin/handlers_crew_timeout_test.go
T
Evan JarrettandClaude Opus 5 788b9e1053 hold/admin: bound the crew tab's identity lookups, and drop backend HTTP/2
Two corrections to the HTTP/2 change, both found by running it.

UpCloud refuses http2_enabled on a backend that is not TLS:

  invalid_params_properties.http2_enabled='Tls must be enabled to enable HTTP2.'

This load balancer reaches both origins over the private network in cleartext,
so the option is not available here at all, and asking for it failed the whole
provision run with a 400 after the frontend had already been changed. Backend
HTTP/2 is removed rather than made conditional: it would need TLS terminated at
the origins, which is a much larger change than it earns. The frontend is the
leg that mattered, and it is enabled and confirmed live — all three domains now
negotiate h2 by ALPN.

The second correction is more interesting: enabling HTTP/2 did not fix the
symptom it was aimed at, and briefly made it look worse. With the browser no
longer rationing itself to ~6 connections, every row of the crew tab now reaches
the server at once, and the 504s went from a flat 10s to a climbing ladder:

  [HTTP/2 504 10026ms] ... [HTTP/2 504 15230ms] ... [HTTP/2 504 25976ms]

HTTP/1.1 had been hiding a server-side limit by throttling the client. The queue
moved; it did not disappear. What is actually slow is resolveHandle, which calls
live identity resolution per row with no bound of its own, so a DID whose
resolution hangs — a did:web on a host that stopped answering, a PDS that
accepts and then stalls — holds its request open until the proxy gives up. The
crew tab issues one request per member, so a hold with hundreds of crew gets
hundreds of chances to hit one.

resolveHandle now bounds each lookup at 2s. A handle is decoration on a row
whose DID is already rendered beside it, so waiting seconds for one and failing
the row when it does not arrive trades something load-bearing for something
cosmetic. Two seconds is far above a warm cached lookup and far below the
proxy's 10s cut, so a slow DID costs its own row a handle and costs the page
nothing.

The quota lookup on the same handler was the other suspect and was measured out:
records(collection, did) is indexed and the aggregation returns immediately on
the production database.

This does not remove the N+1 — the tab still issues one request per member, and
a batch endpoint is the real fix. It removes the failure.

Verified: the guard is covered by a test using a directory that hangs until its
context is cancelled, asserting both that the call returns near the bound and
that it did not return suspiciously early. make lint 0 issues, make test green
across 44 packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AV6Mk2AgghFsNo4HWQEaBV
2026-09-09 09:01:45 -05:00

52 lines
1.7 KiB
Go

package admin
import (
"context"
"testing"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// hangingDirectory stalls until its context is cancelled, standing in for the
// case this guard exists for: a did:web host that accepts the connection and
// then never answers.
type hangingDirectory struct{ identity.Directory }
func (hangingDirectory) LookupDID(ctx context.Context, _ syntax.DID) (*identity.Identity, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func (h hangingDirectory) Lookup(ctx context.Context, _ syntax.AtIdentifier) (*identity.Identity, error) {
<-ctx.Done()
return nil, ctx.Err()
}
// A hanging lookup must give the row back without a handle rather than hold the
// request until the proxy 504s it. Before this bound, one such DID took the
// whole request down with it — and the crew tab issues one request per member.
func TestResolveHandleBoundsAHangingLookup(t *testing.T) {
orig := atproto.GetDirectory()
atproto.SetDirectory(hangingDirectory{})
t.Cleanup(func() { atproto.SetDirectory(orig) })
// A context with no deadline of its own: the bound must come from
// resolveHandle, not from the caller.
start := time.Now()
got := resolveHandle(context.Background(), "did:plc:hdjmrwzdaehsvehbeocsleif")
elapsed := time.Since(start)
if got != "" {
t.Errorf("a failed lookup should yield no handle, got %q", got)
}
if elapsed >= 2*resolveHandleTimeout {
t.Errorf("took %v, expected to be bounded near %v", elapsed, resolveHandleTimeout)
}
if elapsed < resolveHandleTimeout/2 {
t.Errorf("returned in %v, suspiciously fast — did the lookup actually run?", elapsed)
}
}