tinyrelayfile
tinyrelay/internal/daemon/git_services.go/

internal/daemon/git_services.go raw
1package daemon
2
3import (
4 "context"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "net/url"
11 "sort"
12 "strings"
13 "time"
14
15 "github.com/FelineStateMachine/tinyrelay/internal/event"
16 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
17 "github.com/FelineStateMachine/tinyrelay/internal/replication"
18 "github.com/FelineStateMachine/tinyrelay/internal/storage"
19 "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"
20)
21
22// gitSync repairs signed branch state. Conversation history is scheduled
23// separately so a missing Git object cannot block issue or PR replication.
24func (t *Tenant) gitSync(ctx context.Context, repo gitrelay.Repository) error {
25 if len(repo.Refs) == 0 {
26 return nil
27 }
28 repo.Private = repo.Private || t.PrivateServiceEnabled()
29 t.git.ConfigurePrivatePeers(t.Policy().PrivatePeers, t.privateHTTPAuth)
30 return t.git.FetchMissing(ctx, repo, gitrelay.RepairSources(repo), repo.Refs)
31}
32
33func (t *Tenant) gitEventSync(ctx context.Context, repo gitrelay.Repository) error {
34 var result error
35 targets := boundedGitRelays(repo.Relays, repo.Private || t.PrivateServiceEnabled(), t.Policy().PrivatePeers)
36 setting := "grasp02.peer-offset." + repo.Owner + ":" + repo.Identifier
37 var offset int
38 _ = t.store.GetSetting(ctx, setting, &offset)
39 if offset < 0 {
40 offset = 0
41 }
42 targets = rotateGitRelays(targets, offset)
43 historyCtx, historyCancel := context.WithTimeout(ctx, 20*time.Second)
44 for index, target := range targets {
45 if historyCtx.Err() != nil {
46 break
47 }
48 if sameRelayGit(target, t.RelayURL(), t.publicURL) {
49 continue
50 }
51 // Persist the next starting peer before a bounded network attempt.
52 // An unavailable peer cannot monopolize every subsequent pass.
53 result = errors.Join(result, t.store.PutSetting(ctx, setting, (offset+index+1)%len(targets)))
54 peerCtx, cancel := context.WithTimeout(historyCtx, 8*time.Second)
55 result = errors.Join(result, t.gitSyncPeer(peerCtx, repo, target))
56 cancel()
57 }
58 if historyCtx.Err() != nil && ctx.Err() == nil {
59 result = errors.Join(result, replication.ErrPullIncomplete)
60 }
61 historyCancel()
62 repairCtx, repairCancel := context.WithTimeout(ctx, 5*time.Second)
63 result = errors.Join(result, t.repairGitPullRequests(repairCtx, repo))
64 repairCancel()
65 if t.Policy().Features.Grasp03 {
66 outboxCtx, outboxCancel := context.WithTimeout(ctx, 15*time.Second)
67 result = errors.Join(result, t.gitOutboxSync(outboxCtx, repo))
68 outboxCancel()
69 }
70 return gitSyncOutcome(ctx, result)
71}
72
73// gitSyncOutcome classifies a pass for the scheduler. Deadlines from the
74// pass's own time budgets are dropped because peer and outbox offsets persist
75// between passes. A pass whose only remaining condition is more history to
76// fetch reports gitrelay.ErrIncomplete so it continues soon without counting
77// as a failure. Cancellation of the caller's context still propagates.
78func gitSyncOutcome(parent context.Context, err error) error {
79 err = withoutBudgetDeadline(parent, err)
80 if err == nil {
81 return nil
82 }
83 if onlyPullIncomplete(err) {
84 return gitrelay.ErrIncomplete
85 }
86 return err
87}
88
89func withoutBudgetDeadline(parent context.Context, err error) error {
90 if err == nil || parent.Err() != nil {
91 return err
92 }
93 if joined, ok := err.(interface{ Unwrap() []error }); ok {
94 var kept []error
95 for _, item := range joined.Unwrap() {
96 if item = withoutBudgetDeadline(parent, item); item != nil {
97 kept = append(kept, item)
98 }
99 }
100 return errors.Join(kept...)
101 }
102 if errors.Is(err, context.DeadlineExceeded) {
103 return nil
104 }
105 return err
106}
107
108func 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 false
113 }
114 }
115 return true
116 }
117 return errors.Is(err, replication.ErrPullIncomplete)
118}
119
120// gitIngestRejected reports a deterministic admission decision. NIP-01
121// prefixes mark policy rejections that repeat on every attempt; other errors
122// are storage or lookup failures that may succeed later.
123func 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 true
128 }
129 }
130 return false
131}
132
133// gitTargetTransport checks the privacy boundary before sending any filters.
134// An unverified private peer never falls back to an anonymous connection.
135func (t *Tenant) gitTargetTransport(ctx context.Context, repo gitrelay.Repository, target string) (*replication.NostrTransport, error) {
136 p := t.Policy()
137 if !p.Features.Grasp || !p.Features.Grasp02 {
138 return nil, errors.New("GRASP synchronization is disabled")
139 }
140 if !gitOutboxAllowedRelay(target, repo.Private || t.PrivateServiceEnabled(), p.PrivatePeers) {
141 return nil, errors.New("GRASP synchronization target is not allowed")
142 }
143 if repo.Private || t.PrivateServiceEnabled() {
144 if !t.PrivateServiceEnabled() || !privatePeerMatch(target, p.PrivatePeers) || !t.privatePeerReady(ctx, target) {
145 return nil, errors.New("private peer is not ready")
146 }
147 }
148 return &replication.NostrTransport{Dialer: t.gitEventDialer(repo.Private || t.PrivateServiceEnabled()), Timeout: 5 * time.Second, LegacyCache: t.gitLegacy}, nil
149}
150
151func (t *Tenant) gitSyncPeer(ctx context.Context, repo gitrelay.Repository, target string) error {
152 transport, err := t.gitTargetTransport(ctx, repo, target)
153 if err != nil {
154 return err
155 }
156 var result error
157 cursors := make(map[string]struct{})
158 for _, filter := range gitEventFilters(repo) {
159 cursors[gitHistoryCursorKey(target, filter)] = struct{}{}
160 result = errors.Join(result, t.gitPullFilter(ctx, transport, target, filter))
161 }
162 events, err := t.gitConversationEvents(ctx, repo)
163 if err != nil {
164 return errors.Join(result, err)
165 }
166 for _, filter := range gitReplyFilters(events) {
167 cursors[gitHistoryCursorKey(target, filter)] = struct{}{}
168 result = errors.Join(result, t.gitPullFilter(ctx, transport, target, filter))
169 }
170 // Reply filters are chunked by root ID, so their keys shift as roots
171 // arrive. Drop cursors for filters this pass no longer uses.
172 return errors.Join(result, t.store.PruneSettings(ctx, gitHistoryCursorPrefix(target), cursors))
173}
174
175func (t *Tenant) gitPullFilter(ctx context.Context, transport *replication.NostrTransport, target string, filter event.Filter) error {
176 local, err := t.localSyncItems(ctx, filter)
177 if errors.Is(err, replication.ErrPullIncomplete) {
178 return t.gitPullLargeFilter(ctx, transport, target, filter)
179 }
180 if err != nil {
181 return err
182 }
183 items, pullErr := transport.QuerySynchronized(ctx, target, filter, local)
184 return errors.Join(pullErr, t.ingestGitHistory(ctx, filter, items))
185}
186
187// A partial inventory is not safe for negentropy: the resulting missing-ID set
188// can overflow its fetch cap. Refresh a small head first, then resume an older
189// window. A slow historical query cannot prevent new events from being stored.
190func (t *Tenant) gitPullLargeFilter(ctx context.Context, transport *replication.NostrTransport, target string, filter event.Filter) error {
191 olderFilter, hasCursor, err := t.gitHistoryWindow(ctx, target, filter)
192 if err != nil {
193 return err
194 }
195 headCtx, cancelHead := gitQueryContext(ctx, 2*time.Second)
196 head, headErr := queryGitHead(headCtx, transport, target, filter)
197 cancelHead()
198 headIngestErr := t.ingestGitHistory(ctx, filter, head)
199 result := errors.Join(replication.ErrPullIncomplete, headErr, headIngestErr)
200 key := gitHistoryCursorKey(target, filter)
201 if !hasCursor {
202 // A completed bounded head query seeds history; it never proves that
203 // the remote inventory is exhausted. Failed queries cannot move a
204 // timestamp cursor because events may have arrived out of order.
205 if headErr == nil && headIngestErr == nil && len(head) > 0 {
206 result = errors.Join(result, t.store.PutSetting(ctx, key, oldestGitHistoryCursor(head)))
207 }
208 return result
209 }
210 if ctx.Err() != nil {
211 return errors.Join(result, ctx.Err())
212 }
213 olderCtx, cancelOlder := gitQueryContext(ctx, 5*time.Second)
214 older, olderErr := transport.QueryPaginated(olderCtx, target, olderFilter)
215 cancelOlder()
216 olderIngestErr := t.ingestGitHistory(ctx, filter, older)
217 result = errors.Join(result, olderErr, olderIngestErr)
218 if olderIngestErr != nil {
219 return result
220 }
221 if olderErr == nil {
222 // Finished this sweep. The next pass starts another from the head,
223 // allowing older events newly acquired by the peer to be discovered.
224 result = errors.Join(result, t.store.DeleteSetting(ctx, key))
225 } else if errors.Is(olderErr, replication.ErrPullIncomplete) && len(older) > 0 {
226 result = errors.Join(result, t.store.PutSetting(ctx, key, oldestGitHistoryCursor(older)))
227 }
228 return result
229}
230
231func (t *Tenant) ingestGitHistory(ctx context.Context, filter event.Filter, items []event.Event) error {
232 sort.SliceStable(items, func(i, j int) bool { return metadataEventOrder(items[i].Kind) < metadataEventOrder(items[j].Kind) })
233 var result error
234 for _, item := range items {
235 if ctx.Err() != nil {
236 return errors.Join(result, ctx.Err())
237 }
238 if !event.Matches(filter, item) || event.Validate(item) != nil {
239 continue
240 }
241 // A rejected event cannot prevent later valid events from arriving,
242 // and a policy rejection repeats on every attempt, so it counts as
243 // processed. A storage failure keeps the checkpoint so the window is
244 // retried without skipping an event that was not persisted.
245 if err := t.ingest(ctx, item, replication.OriginImport); err != nil && !errors.Is(err, storage.ErrDuplicate) && !errors.Is(err, storage.ErrReplaced) {
246 t.app.telemetry.Logger().Debug("Git sync event rejected", "kind", item.Kind, "error", err)
247 if !gitIngestRejected(err) {
248 result = errors.Join(result, err)
249 }
250 }
251 }
252 return result
253}
254
255const gitHeadPageSize = 256
256
257// Reserve time to ingest the response using the parent context. A query that
258// expires may still have returned valid events worth retaining for the retry.
259func gitQueryContext(ctx context.Context, maximum time.Duration) (context.Context, context.CancelFunc) {
260 if deadline, ok := ctx.Deadline(); ok {
261 if remaining := time.Until(deadline) / 2; remaining < maximum {
262 maximum = remaining
263 }
264 }
265 return context.WithTimeout(ctx, maximum)
266}
267
268func queryGitHead(ctx context.Context, transport replication.PullTransport, target string, filter event.Filter) ([]event.Event, error) {
269 limit := gitHeadPageSize
270 filter.Limit = &limit
271 return transport.Query(ctx, target, filter)
272}
273
274func oldestGitHistoryCursor(items []event.Event) storage.EventCursor {
275 if len(items) == 0 {
276 return storage.EventCursor{}
277 }
278 oldest := items[0]
279 for _, item := range items[1:] {
280 if item.CreatedAt < oldest.CreatedAt || (item.CreatedAt == oldest.CreatedAt && item.ID > oldest.ID) {
281 oldest = item
282 }
283 }
284 return storage.EventCursor{CreatedAt: oldest.CreatedAt, ID: oldest.ID}
285}
286
287func gitHistoryCursorPrefix(target string) string { return "grasp02.history." + target + "." }
288
289func gitHistoryCursorKey(target string, filter event.Filter) string {
290 raw, _ := json.Marshal(filter)
291 sum := sha256.Sum256(raw)
292 return gitHistoryCursorPrefix(target) + hex.EncodeToString(sum[:])
293}
294
295func (t *Tenant) gitHistoryWindow(ctx context.Context, target string, filter event.Filter) (event.Filter, bool, error) {
296 var cursor storage.EventCursor
297 err := t.store.GetSetting(ctx, gitHistoryCursorKey(target, filter), &cursor)
298 if err != nil && !errors.Is(err, sql.ErrNoRows) {
299 return filter, false, err
300 }
301 if cursor.CreatedAt > 0 {
302 until := cursor.CreatedAt
303 filter.Until = &until
304 return filter, true, nil
305 }
306 return filter, false, nil
307}
308
309func (t *Tenant) localSyncItems(ctx context.Context, filter event.Filter) ([]syncprotocol.Item, error) {
310 rows, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10000})
311 if err != nil {
312 return nil, err
313 }
314 // A bounded inventory is intentionally reported as incomplete. The caller
315 // switches to a bounded newest-first pull so a large local history cannot
316 // overflow negentropy's missing-ID fetch cap.
317 items := make([]syncprotocol.Item, 0, len(rows.Events))
318 for _, item := range rows.Events {
319 items = append(items, syncprotocol.Item{ID: item.ID, Timestamp: item.CreatedAt})
320 }
321 if rows.More {
322 return items, replication.ErrPullIncomplete
323 }
324 return items, nil
325}
326
327func (t *Tenant) gitConversationEvents(ctx context.Context, repo gitrelay.Repository) ([]event.Event, error) {
328 var items []event.Event
329 for _, filter := range gitEventFilters(repo)[1:] {
330 rows, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10000})
331 if err != nil {
332 return nil, err
333 }
334 items = append(items, rows.Events...)
335 }
336 roots := uniqueEvents(items)
337 for _, filter := range gitReplyFilters(roots) {
338 rows, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10000})
339 if err != nil {
340 return nil, err
341 }
342 items = append(items, rows.Events...)
343 }
344 return uniqueEvents(items), nil
345}
346
347func gitReplyFilters(items []event.Event) []event.Filter {
348 var ids []string
349 for _, item := range items {
350 if item.Kind == 1617 || item.Kind == 1618 || item.Kind == 1621 {
351 ids = append(ids, item.ID)
352 }
353 }
354 var filters []event.Filter
355 for start := 0; start < len(ids); start += 100 {
356 end := start + 100
357 if end > len(ids) {
358 end = len(ids)
359 }
360 for _, key := range []string{"e", "E"} {
361 filters = append(filters, event.Filter{Kinds: []int{1619, 1111, 1630, 1631, 1632, 1633}, Tags: map[string][]string{key: ids[start:end]}})
362 }
363 }
364 return filters
365}
366
367func (t *Tenant) repairGitPullRequests(ctx context.Context, repo gitrelay.Repository) error {
368 repo.Private = repo.Private || t.PrivateServiceEnabled()
369 items, err := t.gitConversationEvents(ctx, repo)
370 if err != nil {
371 return err
372 }
373 t.git.ConfigurePrivatePeers(t.Policy().PrivatePeers, t.privateHTTPAuth)
374 var result error
375 for _, root := range items {
376 if root.Kind != 1618 {
377 continue
378 }
379 set := gitrelay.PullRequestRepair{Root: root}
380 for _, update := range items {
381 if update.Kind == 1619 && update.PubKey == root.PubKey && event.Tag(update, "E") == root.ID && event.Tag(update, "P") == root.PubKey {
382 set.Updates = append(set.Updates, update)
383 }
384 }
385 err := t.git.RepairPullRequestObjects(ctx, repo, set)
386 result = errors.Join(result, err)
387 if ctx.Err() != nil {
388 return errors.Join(result, ctx.Err())
389 }
390 }
391 return result
392}
393
394func metadataEventOrder(kind int) int {
395 if kind == event.KIND_REPO {
396 return 0
397 }
398 return 1
399}
400
401func (t *Tenant) gitEventDialer(private bool) replication.WebsocketDialer {
402 dialer := replication.WebsocketDialer{AllowPrivate: t.app.cfg.AllowPrivateRelays, MaxMessageBytes: t.app.cfg.MaxMessageBytes}
403 if !private {
404 return dialer
405 }
406 dialer.Authenticate = func(authCtx context.Context, relayURL, challenge string) (event.Event, error) {
407 if !t.PrivateServiceEnabled() || !privatePeerMatch(relayURL, t.Policy().PrivatePeers) {
408 return event.Event{}, errors.New("private peer: authentication is no longer authorized")
409 }
410 return t.records.SignNIP42(authCtx, relayURL, challenge, 0)
411 }
412 return dialer
413}
414
415func gitEventFilters(repo gitrelay.Repository) []event.Filter {
416 authors := append([]string{repo.Owner}, repo.Maintainers...)
417 metadataKinds := []int{event.KIND_REPO, event.KIND_REPO_STATE}
418 collaborationKinds := []int{event.KIND_GIT_PATCH, event.KIND_GIT_PR, event.KIND_GIT_PR_UPDATE, event.KIND_GIT_ISSUE, 1111, 1630, 1631, 1632, 1633}
419 coordinate := "30617:" + repo.Owner + ":" + repo.Identifier
420 return []event.Filter{
421 {Kinds: metadataKinds, Authors: authors, Tags: map[string][]string{"d": {repo.Identifier}}},
422 {Kinds: collaborationKinds, Tags: map[string][]string{"a": {coordinate}}},
423 {Kinds: collaborationKinds, Tags: map[string][]string{"A": {coordinate}}},
424 }
425}
426
427func sameRelayGit(a, b, c string) bool {
428 a, b, c = normalizeRelayGit(a), normalizeRelayGit(b), normalizeRelayGit(c)
429 return a != "" && (a == b || a == c)
430}
431
432func normalizeRelayGit(raw string) string {
433 raw = strings.TrimRight(strings.TrimSpace(raw), "/")
434 u, err := url.Parse(raw)
435 if err != nil {
436 return raw
437 }
438 switch u.Scheme {
439 case "http":
440 u.Scheme = "ws"
441 case "https":
442 u.Scheme = "wss"
443 }
444 u.Host = strings.ToLower(u.Host)
445 return strings.TrimRight(u.String(), "/")
446}
447
448func uniqueEvents(rows []event.Event) []event.Event {
449 seen := make(map[string]struct{}, len(rows))
450 result := make([]event.Event, 0, len(rows))
451 for _, row := range rows {
452 if _, ok := seen[row.ID]; ok {
453 continue
454 }
455 seen[row.ID] = struct{}{}
456 result = append(result, row)
457 }
458 return result
459}
460
461// sortEventsNewestFirst matches the stable storage order used by cursors.
462func sortEventsNewestFirst(rows []event.Event) {
463 sort.Slice(rows, func(i, j int) bool {
464 if rows[i].CreatedAt == rows[j].CreatedAt {
465 return rows[i].ID < rows[j].ID
466 }
467 return rows[i].CreatedAt > rows[j].CreatedAt
468 })
469}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs