package spindle import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "strconv" "strings" "sync" "sync/atomic" "testing" "time" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/gorilla/websocket" "tangled.org/core/api/org_tangled" "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" "tangled.org/core/tapc" ) // a short dir, because unix socket paths are capped at ~104 bytes on darwin // and t.TempDir() alone gets close to that func shortTempDir(t *testing.T) string { t.Helper() dir, err := os.MkdirTemp("/tmp", "bf-") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) return dir } // fakeHydrant serves the parts of hydrant's api the backfill uses type fakeHydrant struct { mu sync.Mutex events []tapc.Event puts [][]syntax.DID erased []syntax.DID tracked map[syntax.DID]bool repos map[syntax.DID]hydrantRepo resynced []syntax.DID onResync func(did syntax.DID) onTrack func(did syntax.DID) // owners hydrant's retry worker requeues every other time spindle looks flapping map[syntax.DID]bool listings int cursors []string starts int failNext error // every start fails, not just the next failStarts error ops []string srv *httptest.Server done chan struct{} shutdowns atomic.Int32 } func (f *fakeHydrant) subscriptions() int { f.mu.Lock() defer f.mu.Unlock() return len(f.cursors) } func (f *fakeHydrant) Done() <-chan struct{} { return f.done } func (f *fakeHydrant) Err() error { return nil } func (f *fakeHydrant) Shutdown(time.Duration) error { f.srv.Close(); f.shutdowns.Add(1); return nil } func (f *fakeHydrant) addRecord(id int64, did, rkey string) { f.mu.Lock() defer f.mu.Unlock() f.events = append(f.events, tapc.Event{ ID: id, Type: tapc.EvtRecord, Record: &tapc.RecordEventData{ Did: syntax.DID(did), Collection: syntax.NSID(org_tangled.RepoDeclarationNSID), Rkey: syntax.RecordKey(rkey), Action: tapc.RecordCreateAction, Record: json.RawMessage(`{}`), }, }) } func (f *fakeHydrant) addEvent(rec tapc.RecordEventData) { f.mu.Lock() defer f.mu.Unlock() f.events = append(f.events, tapc.Event{ID: int64(len(f.events)), Type: tapc.EvtRecord, Record: &rec}) } func (f *fakeHydrant) erasedDids() []syntax.DID { f.mu.Lock() defer f.mu.Unlock() return slices.Clone(f.erased) } // empty for an owner hydrant doesn't know func (f *fakeHydrant) repoStatus(did syntax.DID) string { f.mu.Lock() defer f.mu.Unlock() return f.repos[did].Status } func (f *fakeHydrant) failStartsWith(err error) { f.mu.Lock() defer f.mu.Unlock() f.failStarts = err } // identity events aren't replayed, so only live connections get this one func (f *fakeHydrant) addLiveOnly(id int64, did string) { f.mu.Lock() defer f.mu.Unlock() f.events = append(f.events, tapc.Event{ ID: id, Type: tapc.EvtIdentity, Identity: &tapc.IdentityEventData{DID: syntax.DID(did), IsActive: true}, }) } // setRepo is what hydrant reports for repo.Did, which it then already // tracks. owners nobody set are synced func (f *fakeHydrant) setRepo(repo hydrantRepo) { f.mu.Lock() defer f.mu.Unlock() if f.repos == nil { f.repos = make(map[syntax.DID]hydrantRepo) f.tracked = make(map[syntax.DID]bool) } f.repos[repo.Did] = repo f.tracked[repo.Did] = true } func synced(did syntax.DID) hydrantRepo { return hydrantRepo{Did: did, Status: "synced"} } // retrying is a failed owner hydrant queued again func retrying(did syntax.DID) hydrantRepo { return hydrantRepo{Did: did, Status: "error(transport error)", Pending: true} } func failedWith(did syntax.DID, kind string, hostFailures int) hydrantRepo { return hydrantRepo{ Did: did, Status: "error(" + kind + ")", Resync: &hydrantResync{State: "error", Kind: kind, HostFailures: hostFailures}, } } func (f *fakeHydrant) start(settings map[string]string) (embeddedHydrant, error) { f.mu.Lock() f.starts++ err := f.failNext f.failNext = nil if err == nil { err = f.failStarts } f.mu.Unlock() if err != nil { return nil, err } l, err := net.Listen("unix", strings.TrimPrefix(settings["HYDRANT_API_BIND"], "unix:")) if err != nil { return nil, err } f.srv = httptest.NewUnstartedServer(http.HandlerFunc(f.serve)) f.srv.Listener = l f.srv.Start() f.done = make(chan struct{}) return f, nil } func (f *fakeHydrant) serve(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/stream/head": f.mu.Lock() var head *int64 for _, evt := range f.events { if evt.Type == tapc.EvtRecord { head = &evt.ID } } f.mu.Unlock() json.NewEncoder(w).Encode(map[string]any{"id": head}) case strings.HasPrefix(r.URL.Path, "/redactions/"): did := syntax.DID(strings.TrimPrefix(r.URL.Path, "/redactions/")) f.mu.Lock() f.erased = append(f.erased, did) f.ops = append(f.ops, "erase "+did.String()) delete(f.tracked, did) delete(f.repos, did) // the event stays so its id still counts, only the body goes for i, evt := range f.events { if evt.Record != nil && evt.Record.Did == did { f.events[i].Record = nil } } f.mu.Unlock() w.Write([]byte("{}")) case r.URL.Path == "/repos" && r.Method == http.MethodGet: f.mu.Lock() var repos []hydrantRepo f.listings++ for did := range f.tracked { repo, ok := f.repos[did] if !ok { repo = hydrantRepo{Did: did, Status: "synced"} } if f.flapping[did] && f.listings%2 == 0 { repo.Resync, repo.Pending = nil, true } if r.URL.Query().Get("queued") == "true" && !repo.Pending && repo.Resync == nil { continue } repo.Tracked = true repos = append(repos, repo) } f.mu.Unlock() slices.SortFunc(repos, func(a, b hydrantRepo) int { return strings.Compare(string(a.Did), string(b.Did)) }) if cursor := syntax.DID(r.URL.Query().Get("cursor")); cursor != "" { repos = slices.DeleteFunc(repos, func(repo hydrantRepo) bool { return repo.Did <= cursor }) } if limit, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && len(repos) > limit { repos = repos[:limit] } json.NewEncoder(w).Encode(repos) case r.URL.Path == "/repos/resync": var body []struct { Did syntax.DID `json:"did"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } f.mu.Lock() onResync := f.onResync for _, b := range body { f.resynced = append(f.resynced, b.Did) } f.mu.Unlock() for _, b := range body { if onResync != nil { onResync(b.Did) } } w.Write([]byte("[]")) case r.URL.Path == "/repos": var body []struct { Did syntax.DID `json:"did"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var dids []syntax.DID for _, b := range body { dids = append(dids, b.Did) } slices.Sort(dids) f.mu.Lock() if f.tracked == nil { f.tracked = make(map[syntax.DID]bool) } f.puts = append(f.puts, dids) f.ops = append(f.ops, fmt.Sprintf("put %d", len(dids))) for _, did := range dids { f.tracked[did] = true } onTrack := f.onTrack f.mu.Unlock() for _, did := range dids { if onTrack != nil { onTrack(did) } } w.Write([]byte("[]")) case r.URL.Path == "/stream": f.stream(w, r) default: http.NotFound(w, r) } } func (f *fakeHydrant) stream(w http.ResponseWriter, r *http.Request) { conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) if err != nil { return } defer conn.Close() next, _ := strconv.ParseInt(r.URL.Query().Get("cursor"), 10, 64) f.mu.Lock() f.cursors = append(f.cursors, r.URL.Query().Get("cursor")) connectedAt := len(f.events) f.mu.Unlock() gone := closedWhenDone(conn) for { f.mu.Lock() var events []tapc.Event for i, evt := range f.events { if evt.ID >= next && (evt.Type == tapc.EvtRecord || i >= connectedAt) { events = append(events, evt) } } f.mu.Unlock() for _, evt := range events { if err := conn.WriteJSON(evt); err != nil { return } next = evt.ID + 1 } select { case <-gone: return case <-time.After(5 * time.Millisecond): } } } // the server side of a websocket only learns the client left by reading func closedWhenDone(conn *websocket.Conn) <-chan struct{} { gone := make(chan struct{}) go func() { defer close(gone) for { if _, _, err := conn.ReadMessage(); err != nil { return } } }() return gone } func newTestBackfill(t *testing.T, f *fakeHydrant) (*backfill, *[]int64) { t.Helper() d, _ := newTestSpindleDB(t) b := newBackfill(&Spindle{cfg: &config.Config{}, db: d}, slog.Default(), shortTempDir(t)) b.start = f.start b.poll = 10 * time.Millisecond b.retryBase = time.Millisecond var mu sync.Mutex handled := []int64{} b.handle = func(_ context.Context, evt tapc.Event) error { mu.Lock() defer mu.Unlock() handled = append(handled, evt.ID) return nil } return b, &handled } func runRound(t *testing.T, b *backfill) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := b.round(ctx); err != nil { t.Fatalf("round: %v", err) } } func TestBackfillRoundFeedsTheStreamThenShutsDown(t *testing.T) { f := &fakeHydrant{} f.addRecord(0, "did:plc:a", "one") f.addRecord(1, "did:plc:a", "two") f.addRecord(2, "did:plc:b", "three") b, handled := newTestBackfill(t, f) b.Track("did:plc:b", "did:plc:a") runRound(t, b) if !slices.Equal(*handled, []int64{0, 1, 2}) { t.Fatalf("handled %v, want [0 1 2]", *handled) } if len(f.puts) != 1 || !slices.Equal(f.puts[0], []syntax.DID{"did:plc:a", "did:plc:b"}) { t.Fatalf("puts = %v, want one put of both owners", f.puts) } if f.shutdowns.Load() != 1 { t.Fatalf("shutdowns = %d, want 1", f.shutdowns.Load()) } if last, err := b.loadCursor(); err != nil || last != 2 { t.Fatalf("saved cursor = %d, %v, want 2", last, err) } // a known owner doesn't start hydrant again, and a new one resumes after the cursor b.Track("did:plc:a") runRound(t, b) if f.starts != 1 { t.Fatalf("starts = %d after tracking a known owner, want 1", f.starts) } f.addRecord(3, "did:plc:c", "four") b.Track("did:plc:c") runRound(t, b) if !slices.Equal(*handled, []int64{0, 1, 2, 3}) { t.Fatalf("handled %v, want [0 1 2 3]", *handled) } if !slices.Equal(f.cursors, []string{"0", "3"}) { t.Fatalf("stream cursors = %v, want [0 3]", f.cursors) } } // untracking only remembers the wish, so nothing happens until a round has // something to add. a restart forgets it, and hydrant's copy goes then func TestBackfillUntrackIsLazy(t *testing.T) { f := &fakeHydrant{} b, _ := newTestBackfill(t, f) b.Track("did:plc:a", "did:plc:b") runRound(t, b) b.Untrack("did:plc:b") runRound(t, b) if f.starts != 1 { t.Fatalf("starts = %d, untracking alone must not start hydrant", f.starts) } b.Track("did:plc:c") runRound(t, b) if !slices.Equal(f.erased, []syntax.DID{"did:plc:b"}) { t.Fatalf("erased = %v, want [did:plc:b]", f.erased) } if !slices.Equal(f.puts[len(f.puts)-1], []syntax.DID{"did:plc:c"}) { t.Fatalf("last put = %v, want [did:plc:c]", f.puts[len(f.puts)-1]) } // nothing followed a while it was untracked, so tracking it again has to // delete it first, or hydrant would call it backfilled and skip it b.Untrack("did:plc:a") b.Track("did:plc:a") add, remove := b.take() if !slices.Equal(add, []syntax.DID{"did:plc:a"}) || !slices.Equal(remove, []syntax.DID{"did:plc:a"}) { t.Fatalf("take = %v, %v, want did:plc:a in both", add, remove) } // a new process only knows who it wants, so c goes b, _ = newTestBackfill(t, f) b.Track("did:plc:a") runRound(t, b) if !slices.Equal(f.erased, []syntax.DID{"did:plc:b", "did:plc:c"}) { t.Fatalf("erased = %v, want b and c", f.erased) } } func TestBackfillWaitsForOwnersItCanStillGet(t *testing.T) { f := &fakeHydrant{} f.addRecord(0, "did:plc:a", "one") // a first backfill still running, and a live pds asking hydrant to slow down f.setRepo(hydrantRepo{Did: "did:plc:a", Status: "desynchronized", Pending: true}) f.setRepo(failedWith("did:plc:b", "ratelimited", 0)) b, _ := newTestBackfill(t, f) b.Track("did:plc:a", "did:plc:b") go func() { time.Sleep(200 * time.Millisecond) f.setRepo(synced("did:plc:a")) f.setRepo(synced("did:plc:b")) }() started := time.Now() runRound(t, b) if time.Since(started) < 200*time.Millisecond { t.Fatalf("round ended after %v while owners could still be backfilled", time.Since(started)) } if len(b.failing) != 0 { t.Fatalf("failing = %v, want none", b.failing) } } // hydrant keeps moving owners it failed back into its queue on its own // backoff, so pending hardly ever reads 0. none of that can gain anything this // round, so it mustn't keep the round going func TestBackfillEndsWhileFailedOwnersKeepRetrying(t *testing.T) { f := &fakeHydrant{flapping: map[syntax.DID]bool{"did:plc:b": true}} f.addRecord(0, "did:plc:a", "one") f.setRepo(failedWith("did:plc:b", "generic", 0)) // ratelimited only because its pds is down and hydrant skips it f.setRepo(failedWith("did:plc:c", "ratelimited", 4)) f.setRepo(hydrantRepo{Did: "did:plc:d", Status: "takendown", Resync: &hydrantResync{State: "gone"}}) // hydrant dropped it from its queues without syncing it f.setRepo(hydrantRepo{Did: "did:plc:e", Status: "desynchronized"}) b, handled := newTestBackfill(t, f) b.Track("did:plc:a", "did:plc:b", "did:plc:c", "did:plc:d", "did:plc:e") runRound(t, b) if !slices.Equal(*handled, []int64{0}) { t.Fatalf("handled %v, want [0]", *handled) } // spindle didn't know they failed before, so they got retried straight away if !slices.Equal(f.resynced, []syntax.DID{"did:plc:b", "did:plc:c", "did:plc:e"}) { t.Fatalf("resynced = %v, want b, c and e", f.resynced) } if len(b.failing) != 3 || b.failing["did:plc:b"].rounds != 1 || b.failing["did:plc:e"].rounds != 1 { t.Fatalf("failing = %v, want b, c and e after one round", b.failing) } if wait := time.Until(b.nextRetry()); wait < time.Minute || wait > failedOwnerRetryMin { t.Fatalf("next retry in %v, want about %v", wait, failedOwnerRetryMin) } } // the first round leaves did:plc:a failed, with its retry a backoff away func failedOnce(t *testing.T) (*fakeHydrant, *backfill) { t.Helper() f := &fakeHydrant{} f.setRepo(failedWith("did:plc:a", "transport", 0)) b, _ := newTestBackfill(t, f) b.Track("did:plc:a", "did:plc:b") runRound(t, b) return f, b } // stands in for waiting out the backoff func retryNow(b *backfill) { for did, f := range b.failing { f.retryAt = time.Now() b.failing[did] = f } } func TestBackfillRetriesAFailedOwnerAfterABackoff(t *testing.T) { f, b := failedOnce(t) if wait := time.Until(b.nextRetry()); wait < time.Minute || wait > failedOwnerRetryMin { t.Fatalf("next retry in %v, want about %v", wait, failedOwnerRetryMin) } runRound(t, b) if f.starts != 1 { t.Fatalf("starts = %d before the retry was due, want 1", f.starts) } retryNow(b) before := len(f.resynced) f.onResync = func(did syntax.DID) { f.setRepo(synced(did)) } runRound(t, b) if got := f.resynced[before:]; !slices.Equal(got, []syntax.DID{"did:plc:a"}) { t.Fatalf("resynced %v in the retry round, want did:plc:a", got) } } func TestBackfillBacksOffFurtherWhenTheRetryFails(t *testing.T) { f, b := failedOnce(t) retryNow(b) // hydrant shows the retry queued first, so the old failure can't count // as the new outcome f.onResync = func(did syntax.DID) { f.setRepo(retrying(did)) go func() { time.Sleep(50 * time.Millisecond) f.setRepo(failedWith(did, "transport", 0)) }() } started := time.Now() runRound(t, b) if time.Since(started) < 50*time.Millisecond { t.Fatalf("round ended after %v before the retry finished", time.Since(started)) } if wait := time.Until(b.nextRetry()); wait <= failedOwnerRetryMin { t.Fatalf("next retry in %v, want it backed off past %v", wait, failedOwnerRetryMin) } } func TestBackfillForgetsAFailedOwnerOnceItBackfills(t *testing.T) { f, b := failedOnce(t) retryNow(b) f.onResync = func(did syntax.DID) { f.setRepo(synced(did)) } runRound(t, b) if !b.nextRetry().IsZero() { t.Fatalf("next retry at %v after the owner backfilled, want none", b.nextRetry()) } } // with no new owners coming, run itself has to come back for a failed one func TestBackfillRunComesBackForFailedOwners(t *testing.T) { f := &fakeHydrant{} f.setRepo(failedWith("did:plc:a", "transport", 0)) f.onResync = func(did syntax.DID) { f.mu.Lock() retries := len(f.resynced) f.mu.Unlock() // the first round's retry fails again, and the next one gets through if retries > 1 { f.setRepo(synced(did)) } } b, _ := newTestBackfill(t, f) b.retryFailed = 50 * time.Millisecond b.Track("did:plc:a") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() done := make(chan struct{}) go func() { defer close(done) b.run(ctx) }() for { f.mu.Lock() retries := len(f.resynced) f.mu.Unlock() if retries >= 2 { break } if ctx.Err() != nil { t.Fatal("the failed owner was never retried") } time.Sleep(10 * time.Millisecond) } cancel() <-done if f.starts < 2 { t.Fatalf("starts = %d, want a retry round", f.starts) } } func TestBackfillParksARecordThatKeepsFailing(t *testing.T) { f := &fakeHydrant{} f.addRecord(0, "did:plc:a", "one") f.addRecord(1, "did:plc:a", "bad") f.addRecord(2, "did:plc:a", "three") b, _ := newTestBackfill(t, f) attempts := map[int64]int{} b.handle = func(_ context.Context, evt tapc.Event) error { attempts[evt.ID]++ if evt.ID == 1 { return errors.New("database is locked") } return nil } b.Track("did:plc:a") runRound(t, b) want := map[int64]int{0: 1, 1: backfillEventAttempts, 2: 1} if fmt.Sprint(attempts) != fmt.Sprint(want) { t.Fatalf("attempts = %v, want %v", attempts, want) } if last, err := b.loadCursor(); err != nil || last != 2 { t.Fatalf("saved cursor = %d, %v, want 2", last, err) } parked := dueParked(t, b) if len(parked) != 1 || parked[0].URI != "at://did:plc:a/"+org_tangled.RepoDeclarationNSID+"/bad" || parked[0].Err != "database is locked" { t.Fatalf("parked = %+v, want the bad record with its error", parked) } } // looks a day ahead, so records not due yet come back too func dueParked(t *testing.T, b *backfill) []db.ParkedRecord { t.Helper() parked, err := b.s.db.DueParkedRecords(time.Now().Add(24 * time.Hour)) if err != nil { t.Fatal(err) } return parked } func parkFailing(t *testing.T, b *backfill, f *fakeHydrant) { t.Helper() f.addRecord(0, "did:plc:a", "bad") handle := b.handle b.handle = func(context.Context, tapc.Event) error { return errors.New("database is locked") } b.Track("did:plc:a") runRound(t, b) b.handle = handle if len(dueParked(t, b)) != 1 { t.Fatal("nothing got parked") } } func TestBackfillRetriesAParkedRecordUntilItGoesThrough(t *testing.T) { f := &fakeHydrant{} b, handled := newTestBackfill(t, f) parkFailing(t, b, f) b.retryParkedRecords(context.Background(), time.Now()) if len(*handled) != 0 { t.Fatalf("handled %v before the retry was due", *handled) } fails := 2 handle := b.handle b.handle = func(ctx context.Context, evt tapc.Event) error { if fails > 0 { fails-- return errors.New("still locked") } return handle(ctx, evt) } for range 3 { b.retryParkedRecords(context.Background(), time.Now().Add(24*time.Hour)) } if !slices.Equal(*handled, []int64{0}) { t.Fatalf("handled %v, want the parked record once it worked", *handled) } if parked := dueParked(t, b); len(parked) != 0 { t.Fatalf("parked = %+v after it went through, want none", parked) } } func TestBackfillGivesUpOnAParkedRecordAfterTheLastRetry(t *testing.T) { f := &fakeHydrant{} b, _ := newTestBackfill(t, f) b.retryParked = time.Minute parkFailing(t, b, f) b.handle = func(context.Context, tapc.Event) error { return errors.New("broken for good") } var waits []time.Duration for range parkedRetries - 1 { b.retryParkedRecords(context.Background(), time.Now().Add(24*time.Hour)) parked := dueParked(t, b) if len(parked) != 1 { t.Fatalf("parked = %+v before the last retry", parked) } waits = append(waits, time.Until(parked[0].RetryAt).Round(time.Minute)) } want := []time.Duration{2 * time.Minute, 4 * time.Minute, 8 * time.Minute, 16 * time.Minute, 32 * time.Minute} if !slices.Equal(waits, want) { t.Fatalf("waits = %v, want %v", waits, want) } b.retryParkedRecords(context.Background(), time.Now().Add(24*time.Hour)) if parked := dueParked(t, b); len(parked) != 0 { t.Fatalf("parked = %+v after the last retry, want it given up", parked) } } func TestBackfillRetriesEveryParkedRecordAtStartup(t *testing.T) { f := &fakeHydrant{} b, handled := newTestBackfill(t, f) b.retryParked = time.Hour parkFailing(t, b, f) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() done := make(chan struct{}) go func() { defer close(done) b.run(ctx) }() for len(dueParked(t, b)) != 0 { if ctx.Err() != nil { t.Fatal("the parked record wasn't retried at startup") } time.Sleep(10 * time.Millisecond) } cancel() <-done if !slices.Equal(*handled, []int64{0}) { t.Fatalf("handled %v, want the parked record", *handled) } } func TestBackfillReplaysTheStreamOfANewDb(t *testing.T) { f := &fakeHydrant{} f.addRecord(0, "did:plc:a", "one") b, handled := newTestBackfill(t, f) if err := b.saveCursor(100); err != nil { t.Fatal(err) } b.Track("did:plc:a") runRound(t, b) if !slices.Equal(*handled, []int64{0}) { t.Fatalf("handled %v, want the new db's event", *handled) } } // head only counts records, so identity ids can't move the cursor func TestBackfillCursorSkipsLiveOnlyEvents(t *testing.T) { f := &fakeHydrant{} f.addRecord(0, "did:plc:a", "one") f.setRepo(hydrantRepo{Did: "did:plc:a", Status: "desynchronized", Pending: true}) b, handled := newTestBackfill(t, f) b.Track("did:plc:a") go func() { for f.subscriptions() == 0 { time.Sleep(5 * time.Millisecond) } f.addLiveOnly(1, "did:plc:a") time.Sleep(50 * time.Millisecond) f.setRepo(synced("did:plc:a")) }() runRound(t, b) if last, err := b.loadCursor(); err != nil || last != 0 { t.Fatalf("saved cursor = %d, %v, want the last record's id", last, err) } f.addRecord(2, "did:plc:b", "two") b.Track("did:plc:b") runRound(t, b) if !slices.Equal(*handled, []int64{0, 2}) { t.Fatalf("handled %v, want the new record without replaying the old one", *handled) } } func TestBackfillTracksOwnersInBatches(t *testing.T) { f := &fakeHydrant{} b, _ := newTestBackfill(t, f) var dids []syntax.DID for i := range 2*hydrantDidBatch + 1 { dids = append(dids, syntax.DID(fmt.Sprintf("did:plc:%024d", i))) } b.Track(dids...) runRound(t, b) var sizes []int for _, put := range f.puts { sizes = append(sizes, len(put)) } if !slices.Equal(sizes, []int{hydrantDidBatch, hydrantDidBatch, 1}) { t.Fatalf("put sizes = %v, want two full batches and one more", sizes) } h, err := f.start(b.settings()) if err != nil { t.Fatal(err) } defer h.Shutdown(0) repos, err := newHydrantAPI(b.socketPath()).trackedRepos(context.Background()) if err != nil { t.Fatal(err) } if len(repos) != len(dids) { t.Fatalf("listed %d owners over every page, want %d", len(repos), len(dids)) } } func TestBackfillTakesOwnersAddedDuringARound(t *testing.T) { f := &fakeHydrant{} f.addRecord(0, "did:plc:a", "one") f.setRepo(hydrantRepo{Did: "did:plc:a", Status: "desynchronized", Pending: true}) b, handled := newTestBackfill(t, f) b.Track("did:plc:a") go func() { for f.subscriptions() == 0 { time.Sleep(5 * time.Millisecond) } b.Track("did:plc:b") time.Sleep(50 * time.Millisecond) f.addRecord(1, "did:plc:b", "two") f.setRepo(synced("did:plc:a")) }() runRound(t, b) if !slices.Equal(*handled, []int64{0, 1}) { t.Fatalf("handled %v, want the record of the owner that joined", *handled) } if f.starts != 1 { t.Fatalf("starts = %d, want the owner taken by the running round", f.starts) } } func TestBackfillErasesAnOwnerBeforeTrackingItAgain(t *testing.T) { f := &fakeHydrant{} f.addRecord(0, "did:plc:a", "one") b, handled := newTestBackfill(t, f) b.Track("did:plc:a") runRound(t, b) b.Untrack("did:plc:a") b.Track("did:plc:a") f.onTrack = func(did syntax.DID) { f.addRecord(1, did.String(), "one") } // a fresh cursor replays the erased event, which has no body left os.Remove(b.cursorPath()) runRound(t, b) if !slices.Equal(f.ops, []string{"put 1", "erase did:plc:a", "put 1"}) { t.Fatalf("ops = %v, want the erase before the second put", f.ops) } if !slices.Equal(*handled, []int64{0, 1}) { t.Fatalf("handled %v, want the first round's record and the fresh one", *handled) } } func TestBackfillCatchUpErasesAndTracksOwnersAndRepoDids(t *testing.T) { f := &fakeHydrant{} b, _ := newTestBackfill(t, f) b.s.l = slog.Default() b.refresh = func(context.Context, syntax.DID) error { return nil } if err := b.s.db.AddRepo(db.Repo{Knot: "knot.test", Owner: "did:plc:owner", Rkey: "repo", RepoDid: "did:plc:repo"}); err != nil { t.Fatal(err) } b.Track("did:plc:owner") runRound(t, b) if err := b.CatchUp(); err != nil { t.Fatal(err) } runRound(t, b) want := []string{"put 1", "erase did:plc:owner", "erase did:plc:repo", "put 2"} if !slices.Equal(f.ops, want) { t.Fatalf("ops = %v, want %v", f.ops, want) } } func TestHydrantReadinessNoticesHydrantExit(t *testing.T) { socket := shortTempDir(t) + "/api.sock" l, err := net.Listen("unix", socket) if err != nil { t.Fatal(err) } srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) srv.Listener = l srv.Start() defer srv.Close() h := &fakeHydrant{done: make(chan struct{})} close(h.done) started := time.Now() err = newHydrantAPI(socket).waitReady(context.Background(), h) if err == nil || time.Since(started) > 10*time.Second { t.Fatalf("waitReady = %v after %v, want it to notice hydrant exited", err, time.Since(started)) } } func TestBackfillRequeuesOwnersWhenHydrantFailsToStart(t *testing.T) { f := &fakeHydrant{failNext: errors.New("db is locked")} b, _ := newTestBackfill(t, f) b.Track("did:plc:a") if err := b.round(context.Background()); err == nil { t.Fatal("round succeeded although hydrant didn't start") } runRound(t, b) if len(f.puts) != 1 || !slices.Equal(f.puts[0], []syntax.DID{"did:plc:a"}) { t.Fatalf("puts = %v, the owner should have been retried", f.puts) } } func TestBackfillSettingsAllowPrivateNetworksOnlyInDev(t *testing.T) { for _, dev := range []bool{false, true} { cfg := &config.Config{} cfg.Server.Dev = dev b := newBackfill(&Spindle{cfg: cfg}, slog.Default(), shortTempDir(t)) got, ok := b.settings()["HYDRANT_ALLOW_PRIVATE_NETWORKS"] if ok != dev || (dev && got != "true") { t.Errorf("dev=%v: HYDRANT_ALLOW_PRIVATE_NETWORKS = %q (set: %v)", dev, got, ok) } } } // real hydrant, offline. nothing resolves so nothing backfills, but it has to // start, answer, shut down and let go of its db for the next round func TestBackfillRunsEmbeddedHydrant(t *testing.T) { cfg := &config.Config{} cfg.Server.PlcUrl = "http://127.0.0.1:1" d, _ := newTestSpindleDB(t) b := newBackfill(&Spindle{cfg: cfg, db: d}, slog.Default(), shortTempDir(t)) b.poll = 100 * time.Millisecond b.handle = func(context.Context, tapc.Event) error { return nil } round := func() { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if err := b.round(ctx); err != nil { t.Fatalf("round: %v", err) } } b.Track("did:plc:aaaaaaaaaaaaaaaaaaaaaaaa") round() if _, err := os.Stat(filepath.Join(b.dir, "db")); err != nil { t.Fatalf("hydrant didn't put its db under the backfill dir: %v", err) } // the second start has to reopen the db, and erase the first owner through hydrant's api b.Untrack("did:plc:aaaaaaaaaaaaaaaaaaaaaaaa") b.Track("did:plc:bbbbbbbbbbbbbbbbbbbbbbbb") round() }