- commit
- 1ae154cde788fe00226602f3cd9d2febf2f885d3
- parent
- 3a631053c493e479e8b93530165e1c0835f3bbe3
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 189 | ++++++++++++++++++++++++--- |
| A | internal/ | 217 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 29 | ++++ |
| M | internal/ | 3 | +- |
| M | internal/ | 13 | ++- |
| M | internal/ | 50 | +++++++ |
| M | internal/ | 21 | +++- |
@@ -2,6 +2,10 @@ package daemon
22 33 import (44 "context"5+ "crypto/sha256"6+ "database/sql"7+ "encoding/hex"8+ "encoding/json"59 "errors"610 "net/url"711 "sort"@@ -51,6 +55,9 @@ func (t *Tenant) gitEventSync(ctx context.Context, repo gitrelay.Repository) err
5155 result = errors.Join(result, t.gitSyncPeer(peerCtx, repo, target))5256 cancel()5357 }58+ if historyCtx.Err() != nil && ctx.Err() == nil {59+ result = errors.Join(result, replication.ErrPullIncomplete)60+ }5461 historyCancel()5562 repairCtx, repairCancel := context.WithTimeout(ctx, 5*time.Second)5663 result = errors.Join(result, t.repairGitPullRequests(repairCtx, repo))@@ -60,13 +67,25 @@ func (t *Tenant) gitEventSync(ctx context.Context, repo gitrelay.Repository) err
6067 result = errors.Join(result, t.gitOutboxSync(outboxCtx, repo))6168 outboxCancel()6269 }63- return withoutBudgetDeadline(ctx, result)70+ return gitSyncOutcome(ctx, result)71+}72+73+// gitSyncOutcome classifies a pass for the scheduler. Deadlines from the74+// pass's own time budgets are dropped because peer and outbox offsets persist75+// between passes. A pass whose only remaining condition is more history to76+// fetch reports gitrelay.ErrIncomplete so it continues soon without counting77+// as a failure. Cancellation of the caller's context still propagates.78+func gitSyncOutcome(parent context.Context, err error) error {79+ err = withoutBudgetDeadline(parent, err)80+ if err == nil {81+ return nil82+ }83+ if onlyPullIncomplete(err) {84+ return gitrelay.ErrIncomplete85+ }86+ return err6487 }6588 66-// withoutBudgetDeadline drops deadlines from the pass's own time budgets.67-// Peer offsets and outbox offsets persist between passes, so running out of68-// time leaves the repository incomplete rather than failed and keeps it on the69-// regular cadence. Cancellation of the caller's context still propagates.7089 func withoutBudgetDeadline(parent context.Context, err error) error {7190 if err == nil || parent.Err() != nil {7291 return err@@ -86,6 +105,31 @@ func withoutBudgetDeadline(parent context.Context, err error) error {
86105 return err87106 }88107 108+func onlyPullIncomplete(err error) bool {109+ if joined, ok := err.(interface{ Unwrap() []error }); ok {110+ for _, item := range joined.Unwrap() {111+ if !onlyPullIncomplete(item) {112+ return false113+ }114+ }115+ return true116+ }117+ return errors.Is(err, replication.ErrPullIncomplete)118+}119+120+// gitIngestRejected reports a deterministic admission decision. NIP-01121+// prefixes mark policy rejections that repeat on every attempt; other errors122+// are storage or lookup failures that may succeed later.123+func gitIngestRejected(err error) bool {124+ message := err.Error()125+ for _, prefix := range []string{"blocked:", "invalid:", "restricted:", "auth-required:", "pow:"} {126+ if strings.HasPrefix(message, prefix) {127+ return true128+ }129+ }130+ return false131+}132+89133 // gitTargetTransport checks the privacy boundary before sending any filters.90134 // An unverified private peer never falls back to an anonymous connection.91135 func (t *Tenant) gitTargetTransport(ctx context.Context, repo gitrelay.Repository, target string) (*replication.NostrTransport, error) {@@ -101,7 +145,7 @@ func (t *Tenant) gitTargetTransport(ctx context.Context, repo gitrelay.Repositor
101145 return nil, errors.New("private peer is not ready")102146 }103147 }104- return &replication.NostrTransport{Dialer: t.gitEventDialer(repo.Private || t.PrivateServiceEnabled()), Timeout: 5 * time.Second}, nil148+ return &replication.NostrTransport{Dialer: t.gitEventDialer(repo.Private || t.PrivateServiceEnabled()), Timeout: 5 * time.Second, LegacyCache: t.gitLegacy}, nil105149 }106150 107151 func (t *Tenant) gitSyncPeer(ctx context.Context, repo gitrelay.Repository, target string) error {@@ -125,38 +169,151 @@ func (t *Tenant) gitSyncPeer(ctx context.Context, repo gitrelay.Repository, targ
125169 126170 func (t *Tenant) gitPullFilter(ctx context.Context, transport *replication.NostrTransport, target string, filter event.Filter) error {127171 local, err := t.localSyncItems(ctx, filter)172+ if errors.Is(err, replication.ErrPullIncomplete) {173+ return t.gitPullLargeFilter(ctx, transport, target, filter)174+ }128175 if err != nil {129176 return err130177 }131178 items, pullErr := transport.QuerySynchronized(ctx, target, filter, local)179+ return errors.Join(pullErr, t.ingestGitHistory(ctx, filter, items))180+}181+182+// A partial inventory is not safe for negentropy: the resulting missing-ID set183+// can overflow its fetch cap. Refresh a small head first, then resume an older184+// window. A slow historical query cannot prevent new events from being stored.185+func (t *Tenant) gitPullLargeFilter(ctx context.Context, transport *replication.NostrTransport, target string, filter event.Filter) error {186+ olderFilter, hasCursor, err := t.gitHistoryWindow(ctx, target, filter)187+ if err != nil {188+ return err189+ }190+ headCtx, cancelHead := gitQueryContext(ctx, 2*time.Second)191+ head, headErr := queryGitHead(headCtx, transport, target, filter)192+ cancelHead()193+ headIngestErr := t.ingestGitHistory(ctx, filter, head)194+ result := errors.Join(replication.ErrPullIncomplete, headErr, headIngestErr)195+ key := gitHistoryCursorKey(target, filter)196+ if !hasCursor {197+ // A completed bounded head query seeds history; it never proves that198+ // the remote inventory is exhausted. Failed queries cannot move a199+ // timestamp cursor because events may have arrived out of order.200+ if headErr == nil && headIngestErr == nil && len(head) > 0 {201+ result = errors.Join(result, t.store.PutSetting(ctx, key, oldestGitHistoryCursor(head)))202+ }203+ return result204+ }205+ if ctx.Err() != nil {206+ return errors.Join(result, ctx.Err())207+ }208+ olderCtx, cancelOlder := gitQueryContext(ctx, 5*time.Second)209+ older, olderErr := transport.QueryPaginated(olderCtx, target, olderFilter)210+ cancelOlder()211+ olderIngestErr := t.ingestGitHistory(ctx, filter, older)212+ result = errors.Join(result, olderErr, olderIngestErr)213+ if olderIngestErr != nil {214+ return result215+ }216+ if olderErr == nil {217+ // Finished this sweep. The next pass starts another from the head,218+ // allowing older events newly acquired by the peer to be discovered.219+ result = errors.Join(result, t.store.PutSetting(ctx, key, storage.EventCursor{}))220+ } else if errors.Is(olderErr, replication.ErrPullIncomplete) && len(older) > 0 {221+ result = errors.Join(result, t.store.PutSetting(ctx, key, oldestGitHistoryCursor(older)))222+ }223+ return result224+}225+226+func (t *Tenant) ingestGitHistory(ctx context.Context, filter event.Filter, items []event.Event) error {132227 sort.SliceStable(items, func(i, j int) bool { return metadataEventOrder(items[i].Kind) < metadataEventOrder(items[j].Kind) })228+ var result error133229 for _, item := range items {134230 if ctx.Err() != nil {135- return errors.Join(pullErr, ctx.Err())231+ return errors.Join(result, ctx.Err())136232 }137233 if !event.Matches(filter, item) || event.Validate(item) != nil {138234 continue139235 }140- // Rejected remote events must not prevent later valid events from arriving.236+ // A rejected event cannot prevent later valid events from arriving,237+ // and a policy rejection repeats on every attempt, so it counts as238+ // processed. A storage failure keeps the checkpoint so the window is239+ // retried without skipping an event that was not persisted.141240 if err := t.ingest(ctx, item, replication.OriginImport); err != nil && !errors.Is(err, storage.ErrDuplicate) && !errors.Is(err, storage.ErrReplaced) {142241 t.app.telemetry.Logger().Debug("Git sync event rejected", "kind", item.Kind, "error", err)242+ if !gitIngestRejected(err) {243+ result = errors.Join(result, err)244+ }245+ }246+ }247+ return result248+}249+250+const gitHeadPageSize = 256251+252+// Reserve time to ingest the response using the parent context. A query that253+// expires may still have returned valid events worth retaining for the retry.254+func gitQueryContext(ctx context.Context, maximum time.Duration) (context.Context, context.CancelFunc) {255+ if deadline, ok := ctx.Deadline(); ok {256+ if remaining := time.Until(deadline) / 2; remaining < maximum {257+ maximum = remaining143258 }144259 }145- return pullErr260+ return context.WithTimeout(ctx, maximum)261+}262+263+func queryGitHead(ctx context.Context, transport *replication.NostrTransport, target string, filter event.Filter) ([]event.Event, error) {264+ limit := gitHeadPageSize265+ filter.Limit = &limit266+ return transport.Query(ctx, target, filter)267+}268+269+func oldestGitHistoryCursor(items []event.Event) storage.EventCursor {270+ if len(items) == 0 {271+ return storage.EventCursor{}272+ }273+ oldest := items[0]274+ for _, item := range items[1:] {275+ if item.CreatedAt < oldest.CreatedAt || (item.CreatedAt == oldest.CreatedAt && item.ID > oldest.ID) {276+ oldest = item277+ }278+ }279+ return storage.EventCursor{CreatedAt: oldest.CreatedAt, ID: oldest.ID}280+}281+282+func gitHistoryCursorKey(target string, filter event.Filter) string {283+ raw, _ := json.Marshal(filter)284+ sum := sha256.Sum256(raw)285+ return "grasp02.history." + target + "." + hex.EncodeToString(sum[:])286+}287+288+func (t *Tenant) gitHistoryWindow(ctx context.Context, target string, filter event.Filter) (event.Filter, bool, error) {289+ var cursor storage.EventCursor290+ err := t.store.GetSetting(ctx, gitHistoryCursorKey(target, filter), &cursor)291+ if err != nil && !errors.Is(err, sql.ErrNoRows) {292+ return filter, false, err293+ }294+ if cursor.CreatedAt > 0 {295+ until := cursor.CreatedAt296+ filter.Until = &until297+ return filter, true, nil298+ }299+ return filter, false, nil146300 }147301 148302 func (t *Tenant) localSyncItems(ctx context.Context, filter event.Filter) ([]syncprotocol.Item, error) {149- rows, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10001})303+ rows, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10000})150304 if err != nil {151305 return nil, err152306 }153- if rows.More {154- return nil, replication.ErrPullIncomplete155- }307+ // A bounded inventory is intentionally reported as incomplete. The caller308+ // switches to a bounded newest-first pull so a large local history cannot309+ // overflow negentropy's missing-ID fetch cap.156310 items := make([]syncprotocol.Item, 0, len(rows.Events))157311 for _, item := range rows.Events {158312 items = append(items, syncprotocol.Item{ID: item.ID, Timestamp: item.CreatedAt})159313 }314+ if rows.More {315+ return items, replication.ErrPullIncomplete316+ }160317 return items, nil161318 }162319 @@ -167,9 +324,6 @@ func (t *Tenant) gitConversationEvents(ctx context.Context, repo gitrelay.Reposi
167324 if err != nil {168325 return nil, err169326 }170- if rows.More {171- return nil, replication.ErrPullIncomplete172- }173327 items = append(items, rows.Events...)174328 }175329 roots := uniqueEvents(items)@@ -178,9 +332,6 @@ func (t *Tenant) gitConversationEvents(ctx context.Context, repo gitrelay.Reposi
178332 if err != nil {179333 return nil, err180334 }181- if rows.More {182- return nil, replication.ErrPullIncomplete183- }184335 items = append(items, rows.Events...)185336 }186337 return uniqueEvents(items), nil@@ -0,0 +1,217 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "net/http"9+ "sort"10+ "testing"11+ "time"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/event"14+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"15+ "github.com/FelineStateMachine/tinyrelay/internal/replication"16+ "github.com/FelineStateMachine/tinyrelay/internal/storage"17+)18+19+// cursorRelay is deliberately a small NIP-01 relay. It applies the filter's20+// limit and until values, which makes the test exercise the same bounded head21+// and paginated older queries used by production sync.22+type cursorRelay struct {23+ events []event.Event24+ stallHistory bool25+}26+27+type cursorSocket struct {28+ relay *cursorRelay29+ responses [][]byte30+}31+32+type cursorDialer struct{ relay *cursorRelay }33+34+func (d cursorDialer) Dial(context.Context, string) (replication.Socket, *http.Response, error) {35+ return &cursorSocket{relay: d.relay}, nil, nil36+}37+38+func (s *cursorSocket) Read(ctx context.Context) ([]byte, error) {39+ if len(s.responses) == 0 {40+ <-ctx.Done()41+ return nil, ctx.Err()42+ }43+ item := s.responses[0]44+ s.responses = s.responses[1:]45+ return item, nil46+}47+48+func (s *cursorSocket) Write(_ context.Context, data []byte) error {49+ var raw []json.RawMessage50+ if err := json.Unmarshal(data, &raw); err != nil || len(raw) < 3 {51+ return nil52+ }53+ var kind, sub string54+ if json.Unmarshal(raw[0], &kind) != nil || json.Unmarshal(raw[1], &sub) != nil || kind != "REQ" {55+ return nil56+ }57+ f, err := event.ParseFilter(raw[2])58+ if err != nil {59+ return err60+ }61+ if f.Until != nil && s.relay.stallHistory {62+ return nil63+ }64+ items := append([]event.Event(nil), s.relay.events...)65+ sort.Slice(items, func(i, j int) bool {66+ if items[i].CreatedAt != items[j].CreatedAt {67+ return items[i].CreatedAt > items[j].CreatedAt68+ }69+ return items[i].ID < items[j].ID70+ })71+ limit := 072+ if f.Limit != nil {73+ limit = *f.Limit74+ }75+ for _, item := range items {76+ if !event.Matches(f, item) {77+ continue78+ }79+ if limit > 0 && len(s.responses) >= limit {80+ break81+ }82+ message, _ := json.Marshal([]any{"EVENT", sub, item})83+ s.responses = append(s.responses, message)84+ }85+ eose, _ := json.Marshal([]any{"EOSE", sub})86+ s.responses = append(s.responses, eose)87+ return nil88+}89+90+func (s *cursorSocket) Close(error) error { return nil }91+92+func TestGitPullFilterAdvancesBeyondBoundedLocalInventory(t *testing.T) {93+ _, tenant := testTenant(t)94+ owner := tenant.Policy().Owner95+ secret := "0000000000000000000000000000000000000000000000000000000000000001"96+ now := time.Now().Unix()97+ filter := event.Filter{Authors: []string{owner}, Kinds: []int{1}}98+ for i := 0; i < 10001; i++ {99+ // Seed the stored inventory directly; remote responses below still100+ // undergo real signature validation and the normal ingestion path.101+ item := event.Event{ID: fmt.Sprintf("%064x", i+1), PubKey: owner, Kind: 1, CreatedAt: now - int64(i+10000), Tags: [][]string{}, Content: "local"}102+ if _, err := tenant.store.Save(context.Background(), item, storage.SaveOptions{Now: now, SearchMode: tenant.Policy().Features.Search}); err != nil {103+ t.Fatal(err)104+ }105+ }106+ remote := &cursorRelay{}107+ for i := 0; i < gitHeadPageSize+1; i++ {108+ item := event.Event{Kind: 1, CreatedAt: now - int64(i+1), Tags: [][]string{}, Content: "head"}109+ if err := event.Sign(&item, secret); err != nil {110+ t.Fatal(err)111+ }112+ remote.events = append(remote.events, item)113+ }114+ older := event.Event{Kind: 1, CreatedAt: now - 1000, Tags: [][]string{}, Content: "older"}115+ if err := event.Sign(&older, secret); err != nil {116+ t.Fatal(err)117+ }118+ remote.events = append(remote.events, older)119+ transport := &replication.NostrTransport{Dialer: cursorDialer{relay: remote}, Timeout: time.Second}120+121+ if err := tenant.gitPullFilter(context.Background(), transport, "ws://cursor.test", filter); !errors.Is(err, replication.ErrPullIncomplete) {122+ t.Fatalf("first pull error = %v, want incomplete", err)123+ }124+ var cursor storage.EventCursor125+ if err := tenant.store.GetSetting(context.Background(), gitHistoryCursorKey("ws://cursor.test", filter), &cursor); err != nil || cursor.CreatedAt == 0 {126+ t.Fatalf("head cursor was not persisted: %v %#v", err, cursor)127+ }128+ if got := countContent(t, tenant, "older"); got != 0 {129+ t.Fatalf("older event arrived during head seed: %d", got)130+ }131+ if err := tenant.gitPullFilter(context.Background(), transport, "ws://cursor.test", filter); !errors.Is(err, replication.ErrPullIncomplete) {132+ t.Fatalf("second pull error = %v, want incomplete local inventory", err)133+ }134+ if got := countContent(t, tenant, "older"); got != 1 {135+ t.Fatalf("older event count = %d, want 1", got)136+ }137+138+ if err := tenant.store.GetSetting(context.Background(), gitHistoryCursorKey("ws://cursor.test", filter), &cursor); err != nil {139+ t.Fatal(err)140+ }141+ before := cursor142+ ctx, cancel := context.WithCancel(context.Background())143+ cancel()144+ if err := tenant.gitPullFilter(ctx, transport, "ws://cursor.test", filter); err == nil {145+ t.Fatal("canceled pull returned nil")146+ }147+ var after storage.EventCursor148+ if err := tenant.store.GetSetting(context.Background(), gitHistoryCursorKey("ws://cursor.test", filter), &after); err != nil {149+ t.Fatal(err)150+ }151+ if after != before {152+ t.Fatalf("canceled pull advanced cursor from %#v to %#v", before, after)153+ }154+ // A stalled historical window must not consume the head or move its155+ // checkpoint, even when the outer pass has a short deadline.156+ before = storage.EventCursor{CreatedAt: older.CreatedAt, ID: older.ID}157+ if err := tenant.store.PutSetting(context.Background(), gitHistoryCursorKey("ws://cursor.test", filter), before); err != nil {158+ t.Fatal(err)159+ }160+ newest := event.Event{Kind: 1, CreatedAt: now, Tags: [][]string{}, Content: "live-head"}161+ if err := event.Sign(&newest, secret); err != nil {162+ t.Fatal(err)163+ }164+ remote.events = []event.Event{newest}165+ remote.stallHistory = true166+ transport.Timeout = time.Minute167+ ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second)168+ defer cancel()169+ if err := tenant.gitPullFilter(ctx, transport, "ws://cursor.test", filter); !errors.Is(err, context.DeadlineExceeded) {170+ t.Fatalf("stalled history error = %v", err)171+ }172+ if got := countContent(t, tenant, "live-head"); got != 1 {173+ t.Fatalf("stalled history blocked newest event: %d", got)174+ }175+ if err := tenant.store.GetSetting(context.Background(), gitHistoryCursorKey("ws://cursor.test", filter), &after); err != nil {176+ t.Fatal(err)177+ }178+ if after != before {179+ t.Fatalf("stalled history moved cursor from %#v to %#v", before, after)180+ }181+}182+183+func countContent(t *testing.T, tenant *Tenant, content string) int {184+ t.Helper()185+ rows, err := tenant.store.Query(context.Background(), event.Filter{Kinds: []int{1}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}})186+ if err != nil {187+ t.Fatal(err)188+ }189+ count := 0190+ for _, item := range rows.Events {191+ if item.Content == content {192+ count++193+ }194+ }195+ return count196+}197+198+func TestGitSyncOutcomeClassifiesIncompleteAndBudgetDeadlines(t *testing.T) {199+ ctx := context.Background()200+ if got := gitSyncOutcome(ctx, errors.Join(context.DeadlineExceeded, replication.ErrPullIncomplete)); !errors.Is(got, gitrelay.ErrIncomplete) || errors.Is(got, replication.ErrPullIncomplete) {201+ t.Fatalf("incomplete pass should map to gitrelay.ErrIncomplete, got %v", got)202+ }203+ if got := gitSyncOutcome(ctx, errors.Join(context.DeadlineExceeded, errors.New("dial failed"))); got == nil || errors.Is(got, gitrelay.ErrIncomplete) || errors.Is(got, context.DeadlineExceeded) {204+ t.Fatalf("real errors must survive with deadlines dropped, got %v", got)205+ }206+ if got := gitSyncOutcome(ctx, context.DeadlineExceeded); got != nil {207+ t.Fatalf("a bare budget deadline is not a failure, got %v", got)208+ }209+ canceled, cancel := context.WithCancel(ctx)210+ cancel()211+ if got := gitSyncOutcome(canceled, context.DeadlineExceeded); got == nil {212+ t.Fatal("a canceled caller context must propagate")213+ }214+ if !gitIngestRejected(errors.New("blocked: this pubkey is banned")) || gitIngestRejected(errors.New("check pubkey ban: disk I/O error")) {215+ t.Fatal("rejection classification must follow NIP-01 prefixes")216+ }217+}@@ -2,9 +2,16 @@ package daemon
22 33 import (44 "context"5+ "errors"6+ "fmt"7+ "strings"58 "testing"9+ "time"610 11+ "github.com/FelineStateMachine/tinyrelay/internal/event"712 "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"13+ "github.com/FelineStateMachine/tinyrelay/internal/replication"14+ "github.com/FelineStateMachine/tinyrelay/internal/storage"815 )916 1017 func TestGitTargetTransportRequiresFeatureAndPrivatePeer(t *testing.T) {@@ -23,3 +30,25 @@ func TestGitTargetTransportRequiresFeatureAndPrivatePeer(t *testing.T) {
2330 t.Fatal("private repository could send filters to an unconfigured peer")2431 }2532 }33+34+func TestLocalSyncItemsUsesBoundedInventoryWindow(t *testing.T) {35+ _, tenant := testTenant(t)36+ owner := tenant.Policy().Owner37+ now := time.Now().Unix()38+ for i := 0; i < 10001; i++ {39+ item := event.Event{ID: fmt.Sprintf("%064x", i+1), PubKey: owner, CreatedAt: now - int64(i), Kind: 1, Tags: [][]string{}, Content: "sync window"}40+ if _, err := tenant.store.Save(context.Background(), item, storage.SaveOptions{Now: now, SearchMode: tenant.Policy().Features.Search}); err != nil {41+ t.Fatalf("save event %d: %v", i, err)42+ }43+ }44+ items, err := tenant.localSyncItems(context.Background(), event.Filter{Authors: []string{owner}, Kinds: []int{1}})45+ if !errors.Is(err, replication.ErrPullIncomplete) {46+ t.Fatalf("inventory error = %v, want ErrPullIncomplete", err)47+ }48+ if len(items) != 10000 {49+ t.Fatalf("inventory size = %d, want bounded 10000", len(items))50+ }51+ if strings.TrimSpace(items[0].ID) == "" {52+ t.Fatal("bounded inventory returned an empty event ID")53+ }54+}@@ -64,6 +64,7 @@ type Tenant struct {
6464 policyWrite sync.Mutex6565 policy policy.Policy6666 maintenance maintenanceGate67+ gitLegacy *replication.LegacyCache6768 }6869 6970 func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) {@@ -82,7 +83,7 @@ func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) {
8283 return nil, fmt.Errorf("preserve legacy repository privacy: %w", err)8384 }8485 }85- t := &Tenant{app: cfg.app, meta: cfg.meta, store: cfg.store, policy: p, publicURL: cfg.publicURL, auth: auth.NewValidator(time.Now), schedulerWake: make(chan struct{}, 1)}86+ t := &Tenant{app: cfg.app, meta: cfg.meta, store: cfg.store, policy: p, publicURL: cfg.publicURL, auth: auth.NewValidator(time.Now), schedulerWake: make(chan struct{}, 1), gitLegacy: &replication.LegacyCache{}}8687 t.community, err = community.New(ctx, t.store, t.policy.Owner)8788 if err != nil {8889 return nil, err@@ -282,7 +282,13 @@ func (s *Service) Tick(ctx context.Context) error {
282282 }283283 cancel()284284 }285- if repoErr != nil {285+ if errors.Is(repoErr, ErrIncomplete) {286+ // Progress is saved; continue soon without recording a failure.287+ if previous, ok := previousProgress.Repos[repoKey]; ok {288+ status.SucceededAt = previous.SucceededAt289+ }290+ status.NextAt = progress.At + graspRetryDelay291+ } else if repoErr != nil {286292 status.Error = repoErr.Error()287293 status.NextAt = progress.At + graspRetryDelay288294 tickErr = errors.Join(tickErr, fmt.Errorf("%s synchronization: %w", last, repoErr))@@ -924,6 +930,11 @@ func (g *GitRelay) validateRefs(refs map[string]string) error {
924930 return nil925931 }926932 933+// ErrIncomplete reports a synchronization pass that saved progress and has934+// more history to fetch. The scheduler continues it soon without counting a935+// failure.936+var ErrIncomplete = errors.New("synchronization incomplete")937+927938 // Capabilities returns only protocol surfaces that this instance can serve.928939 func (g *GitRelay) Capabilities() []string {929940 p := g.policy()@@ -256,6 +256,49 @@ type NostrTransport struct {
256256 sequence atomic.Uint64257257 legacyMu sync.Mutex258258 legacy map[string]struct{}259+ // LegacyCache may be shared by short-lived transports created for the same260+ // tenant. Entries expire so a relay can gain NIP-77 support without a261+ // process restart.262+ LegacyCache *LegacyCache263+}264+265+const legacyProbeTTL = 4 * time.Hour266+267+type LegacyCache struct {268+ mu sync.Mutex269+ entries map[string]time.Time270+}271+272+func (c *LegacyCache) known(target string) bool {273+ c.mu.Lock()274+ defer c.mu.Unlock()275+ when, ok := c.entries[target]276+ if !ok || time.Since(when) >= legacyProbeTTL {277+ if ok {278+ delete(c.entries, target)279+ }280+ return false281+ }282+ return true283+}284+285+func (c *LegacyCache) remember(target string) {286+ c.mu.Lock()287+ defer c.mu.Unlock()288+ if c.entries == nil {289+ c.entries = make(map[string]time.Time)290+ }291+ if len(c.entries) >= 64 {292+ var oldest string293+ var oldestAt time.Time294+ for key, at := range c.entries {295+ if oldest == "" || at.Before(oldestAt) {296+ oldest, oldestAt = key, at297+ }298+ }299+ delete(c.entries, oldest)300+ }301+ c.entries[target] = time.Now()259302 }260303 261304 // Subscribe keeps a bounded NIP-01 subscription open until ctx is canceled.@@ -384,6 +427,9 @@ func (t *NostrTransport) QuerySynchronized(ctx context.Context, target string, f
384427 }385428 386429 func (t *NostrTransport) knownLegacy(target string) bool {430+ if t.LegacyCache != nil && t.LegacyCache.known(target) {431+ return true432+ }387433 t.legacyMu.Lock()388434 defer t.legacyMu.Unlock()389435 _, ok := t.legacy[target]@@ -391,6 +437,10 @@ func (t *NostrTransport) knownLegacy(target string) bool {
391437 }392438 393439 func (t *NostrTransport) rememberLegacy(target string) {440+ if t.LegacyCache != nil {441+ t.LegacyCache.remember(target)442+ return443+ }394444 t.legacyMu.Lock()395445 defer t.legacyMu.Unlock()396446 if t.legacy == nil {@@ -3,6 +3,7 @@ package replication
33 import (44 "context"55 "encoding/json"6+ "fmt"67 "net/http"78 "testing"89 "time"@@ -63,8 +64,9 @@ func TestQuerySynchronizedFallsBackAfterSilentLegacyNegentropyPeer(t *testing.T)
6364 6465 func TestQuerySynchronizedCachesSilentLegacyPeerPerTransport(t *testing.T) {6566 dialer := &silentNegentropyDialer{}66- transport := &NostrTransport{Dialer: dialer, Timeout: 10 * time.Millisecond}67+ cache := &LegacyCache{}6768 for i := 0; i < 2; i++ {69+ transport := &NostrTransport{Dialer: dialer, Timeout: 10 * time.Millisecond, LegacyCache: cache}6870 if _, err := transport.QuerySynchronized(context.Background(), "ws://legacy.example", event.Filter{}, nil); err != nil {6971 t.Fatal(err)7072 }@@ -85,3 +87,20 @@ func TestQueryNegentropyIDsReportsMissingRequestedEvents(t *testing.T) {
8587 t.Fatalf("returned %d events for missing ID", len(items))8688 }8789 }90+91+func TestLegacyCacheExpiresAndBoundsEntries(t *testing.T) {92+ cache := &LegacyCache{entries: map[string]time.Time{93+ "ws://expired.example": time.Now().Add(-legacyProbeTTL - time.Second),94+ }}95+ if cache.known("ws://expired.example") {96+ t.Fatal("expired legacy probe was reused")97+ }98+ for i := 0; i < 65; i++ {99+ cache.remember(fmt.Sprintf("ws://relay-%d.example", i))100+ }101+ cache.mu.Lock()102+ defer cache.mu.Unlock()103+ if len(cache.entries) != 64 {104+ t.Fatalf("cache entries = %d, want 64", len(cache.entries))105+ }106+}