~james/core

core/spindle/notify_event.go -rw-r--r-- 15.3 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
package spindle

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/hashicorp/golang-lru/v2/expirable"
	"tangled.org/core/api/org_tangled"
	"tangled.org/core/gitutil"
	"tangled.org/core/knotfeed"
	"tangled.org/core/log"
	"tangled.org/core/spindle/db"
	"tangled.org/core/spindle/knotclient"
	"tangled.org/core/spindle/models"
	"tangled.org/core/spindle/xrpc"
	"tangled.org/core/tid"
	"tangled.org/core/workflow"
)

const (
	notifyWorkBudget   = 5 * time.Minute
	defaultNotifySlots = 64
	notifyMinShare     = 2
	notifyWaitWindow   = 2 * time.Minute

	// unknown repos cost two reads each and get a pool of their own
	notifyMaxUnknown        = 2
	notifyMaxUnknownPerKnot = 1
	unknownRepoMemory       = time.Minute
	unknownRepoMemoryMax    = 4096

	// keep pushes an hour longer because the knot's outbox gives up after 3 days
	pushMemory = 3*24*time.Hour + time.Hour

	changedFileOverheadBytes = 48
	changedFilesMaxBytes     = 524288
	changedFilesMaxCount     = 8192
)

// xrpc gets the status through HTTPStatus since it can't import spindle
type notifyStatus struct {
	status int
	err    error
}

func (e notifyStatus) Error() string   { return e.err.Error() }
func (e notifyStatus) Unwrap() error   { return e.err }
func (e notifyStatus) HTTPStatus() int { return e.status }

// every cap keys on this form because the repos table and did docs spell hosts differently
func canonicalKnotHost(host string) string {
	name, port, hasPort := strings.Cut(strings.ToLower(host), ":")
	name = strings.TrimSuffix(name, ".")
	if hasPort {
		return name + ":" + port
	}
	return name
}

type slotLimits struct {
	pool int
	// a new knot always finds a slot because one knot can't use up the reserve
	reserve   int
	dedicated int
	priority  map[string]bool
}

func slotReserve(pool int) int { return max(2, pool/8) }

// only knots with work get entries, which keeps this from growing with every knot seen
type notifySlots struct {
	mu        sync.Mutex
	held      map[string]int
	total     int
	prioHeld  map[string]int
	prioTotal int
	waiting   map[string]time.Time
	swept     time.Time
	now       func() time.Time
}

func (n *notifySlots) clock() time.Time {
	if n.now != nil {
		return n.now()
	}
	return time.Now()
}

// a refused knot still goes in waiting, which shrinks everyone else's share
func (n *notifySlots) acquire(host string, lim slotLimits) bool {
	host = canonicalKnotHost(host)
	n.mu.Lock()
	defer n.mu.Unlock()
	now := n.clock()
	n.forgetStale(now)

	if lim.priority[host] && n.prioTotal < lim.dedicated {
		if n.prioHeld == nil {
			n.prioHeld = map[string]int{}
		}
		n.prioHeld[host]++
		n.prioTotal++
		return true
	}

	limit := max(1, lim.pool-lim.reserve)
	if others := n.othersWaiting(host); others > 0 {
		limit = max(notifyMinShare, lim.pool/(others+1))
	}
	if n.total >= lim.pool || n.held[host] >= limit {
		if n.waiting == nil {
			n.waiting = map[string]time.Time{}
		}
		n.waiting[host] = now
		return false
	}
	if n.held == nil {
		n.held = map[string]int{}
	}
	n.held[host]++
	n.total++
	delete(n.waiting, host)
	return true
}

func (n *notifySlots) release(host string) {
	host = canonicalKnotHost(host)
	n.mu.Lock()
	defer n.mu.Unlock()
	if n.held[host] == 0 {
		n.prioTotal--
		if n.prioHeld[host]--; n.prioHeld[host] == 0 {
			delete(n.prioHeld, host)
		}
		return
	}
	n.total--
	if n.held[host]--; n.held[host] == 0 {
		delete(n.held, host)
	}
}

func (n *notifySlots) inflight() int {
	n.mu.Lock()
	defer n.mu.Unlock()
	return n.total + n.prioTotal
}

func (n *notifySlots) othersWaiting(host string) int {
	count := len(n.waiting)
	if _, ok := n.waiting[host]; ok {
		count--
	}
	return count
}

func (n *notifySlots) forgetStale(now time.Time) {
	if now.Sub(n.swept) < notifyWaitWindow {
		return
	}
	n.swept = now
	for host, at := range n.waiting {
		if now.Sub(at) >= notifyWaitWindow {
			delete(n.waiting, host)
		}
	}
}

func priorityKnotHosts(knots []string) map[string]bool {
	hosts := map[string]bool{}
	for _, knot := range knots {
		host := strings.TrimSpace(knot)
		if rest, ok := strings.CutPrefix(strings.ToLower(host), "did:web:"); ok {
			host = strings.ReplaceAll(rest, "%3a", ":")
		}
		if host != "" {
			hosts[canonicalKnotHost(host)] = true
		}
	}
	return hosts
}

type notifyAdmission struct {
	mu    sync.Mutex
	held  map[string]int
	total int

	once   sync.Once
	missed *expirable.LRU[syntax.DID, struct{}]
}

func (a *notifyAdmission) acquire(host string) bool {
	host = canonicalKnotHost(host)
	a.mu.Lock()
	defer a.mu.Unlock()
	if a.total >= notifyMaxUnknown || a.held[host] >= notifyMaxUnknownPerKnot {
		return false
	}
	if a.held == nil {
		a.held = map[string]int{}
	}
	a.held[host]++
	a.total++
	return true
}

func (a *notifyAdmission) release(host string) {
	host = canonicalKnotHost(host)
	a.mu.Lock()
	defer a.mu.Unlock()
	a.total--
	if a.held[host]--; a.held[host] == 0 {
		delete(a.held, host)
	}
}

func (a *notifyAdmission) memory() *expirable.LRU[syntax.DID, struct{}] {
	a.once.Do(func() {
		a.missed = expirable.NewLRU[syntax.DID, struct{}](unknownRepoMemoryMax, nil, unknownRepoMemory)
	})
	return a.missed
}

func (a *notifyAdmission) missedRecently(did syntax.DID) bool {
	_, ok := a.memory().Get(did)
	return ok
}

func (a *notifyAdmission) miss(did syntax.DID) {
	a.memory().Add(did, struct{}{})
}

// an error makes the knot retry, an empty id means nothing ran
func (s *Spindle) NotifyEvent(ctx context.Context, k knotclient.Knot, repoDid syntax.DID, event *org_tangled.EventNotifyEvent_Input_Event) (models.PipelineId, error) {
	push := event.EventPush
	if push == nil {
		return "", errors.New("only push events come from a knot")
	}

	key := fmt.Sprintf("%s %s %s %s", repoDid, push.Ref, push.OldSha, push.NewSha)
	// the work outlives a knot hanging up, so its retry joins it instead of redoing it
	res := s.pushGroup.DoChan(key, func() (any, error) {
		s.notifyWork.Add(1)
		defer s.notifyWork.Done()
		work, cancel := context.WithTimeout(context.WithoutCancel(ctx), notifyWorkBudget)
		defer cancel()
		// shutdown has to cancel this, or git keeps writing after the db closes
		if s.rootCtx != nil {
			defer context.AfterFunc(s.rootCtx, cancel)()
		}
		return s.runPush(work, k, repoDid, push)
	})
	select {
	case r := <-res:
		pipeline, _ := r.Val.(models.PipelineId)
		return pipeline, r.Err
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func (s *Spindle) acquireNotifySlot(host string) (release func(), ok bool) {
	if !s.notifySlots.acquire(host, s.notifyLimits()) {
		return nil, false
	}
	return func() { s.notifySlots.release(host) }, true
}

func (s *Spindle) notifySlotCount() int {
	if n := s.cfg.Server.NotifySlots; n > 0 {
		return n
	}
	return defaultNotifySlots
}

func (s *Spindle) notifyLimits() slotLimits {
	pool := s.notifySlotCount()
	dedicated := s.cfg.Server.PriorityNotifySlots
	if dedicated <= 0 {
		dedicated = pool / 4
	}
	s.notifyPriorityOnce.Do(func() { s.notifyPriorityHosts = s.priorityKnots() })
	return slotLimits{pool: pool, reserve: slotReserve(pool), dedicated: dedicated, priority: s.notifyPriorityHosts}
}

func (s *Spindle) runPush(ctx context.Context, k knotclient.Knot, repoDid syntax.DID, push *org_tangled.EventPush) (pipeline models.PipelineId, err error) {
	defer func() {
		if err != nil && !errors.Is(err, xrpc.ErrNotifyBusy) {
			log.FromContext(ctx).Error("notify event failed", "repo", repoDid, "err", err)
		}
	}()
	defer func() { err = refusedAsStatus(err) }()

	repo, err := s.repoForPush(ctx, k, repoDid)
	if err != nil || repo == nil {
		return "", err
	}
	release, ok := s.acquireNotifySlot(k.Host)
	if !ok {
		return "", xrpc.ErrNotifyBusy
	}
	defer release()

	return s.handlePush(ctx, k, repo, push)
}

// a miss asks the knot because db only holds repos that passed ensureRepo
func (s *Spindle) repoForPush(ctx context.Context, k knotclient.Knot, repoDid syntax.DID) (*db.Repo, error) {
	repo, err := s.db.GetRepoByDid(repoDid)
	if err == nil {
		return repo, nil
	}
	if !errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("looking up repo %s: %w", repoDid, err)
	}
	if s.notifyAdmission.missedRecently(repoDid) {
		return nil, nil
	}
	if !s.notifyAdmission.acquire(k.Host) {
		return nil, xrpc.ErrNotifyBusy
	}
	defer s.notifyAdmission.release(k.Host)

	repo, err = s.ensureRepo(ctx, k, repoDid)
	if err != nil {
		return nil, fmt.Errorf("looking up repo %s: %w", repoDid, err)
	}
	if repo == nil {
		s.notifyAdmission.miss(repoDid)
		log.FromContext(ctx).Info("ignoring event for a repo this spindle does not serve", "repo", repoDid)
	}
	return repo, nil
}

// the knot drops an event on any 4xx, keep those for refusals a retry can't fix
func refusedAsStatus(err error) error {
	if errors.Is(err, errRevRefused) || errors.Is(err, errTooManyWorkflowFiles) {
		return notifyStatus{http.StatusUnprocessableEntity, err}
	}
	return err
}

func (s *Spindle) handlePush(ctx context.Context, k knotclient.Knot, repo *db.Repo, push *org_tangled.EventPush) (models.PipelineId, error) {
	l := log.FromContext(ctx).With("repo", repo.RepoDid, "ref", push.Ref)

	if !isMaterializedRef(push.Ref) {
		l.Info("skipping push outside heads and tags")
		return "", nil
	}
	oldSha, oldOk := knotfeed.ParseObjectID(push.OldSha)
	newSha, newOk := knotfeed.ParseObjectID(push.NewSha)
	if !oldOk || !newOk {
		// this 422 is final since a retry sends the same shas
		return "", notifyStatus{http.StatusUnprocessableEntity, fmt.Errorf("push shas %q and %q are not object ids", push.OldSha, push.NewSha)}
	}
	if newSha.IsZero() {
		l.Info("ref deleted, nothing to trigger")
		return "", nil
	}
	// bans apply even when the repo isn't banned, because the knot names the pusher
	if ban, err := s.db.GetBan(syntax.DID(*push.Actor)); err != nil {
		return "", fmt.Errorf("checking bans: %w", err)
	} else if ban != nil {
		l.Warn("push by a banned user, running nothing", "actor", *push.Actor)
		return "", nil
	}
	// the ban check runs before the fetch, so a banned repo costs no git work
	if gate, err := s.db.Gate(&s.cfg.Server, repo.RepoDid); err != nil {
		return "", fmt.Errorf("checking admission: %w", err)
	} else if gate.Banned || gate.Paused {
		l.Info("repo is banned or its owner isn't admitted, running nothing for the push")
		return "", nil
	}

	fire := func() {
		s.wh.FirePush(context.WithoutCancel(ctx), repo, *push.Actor, push.Ref, oldSha.String(), newSha.String())
	}
	if id, done, err := s.admitPush(l, repo, push, oldSha, newSha, fire); done || err != nil {
		return id, err
	}

	repoCloneUri := s.knots.CloneURL(k, repo.RepoDid)
	repoPath := s.newRepoPath(repo.RepoDid)
	if err := s.syncRepo(ctx, repoCloneUri, repoPath, newSha.String()); err != nil {
		return "", err
	}
	l.Info("synced git repo")

	changedFiles, _ := changedFilesUnderBudget(ctx, l, repoPath, oldSha, newSha)

	triggerRepo, err := s.buildTriggerRepo(ctx, repo)
	if err != nil {
		return "", fmt.Errorf("building trigger repo: %w", err)
	}
	if s.scheduler != nil && push.Ref == "refs/heads/"+triggerRepo.DefaultBranch {
		if err := s.scheduler.RefreshRepo(ctx, *repo); err != nil {
			l.Warn("failed to refresh schedules after default-branch push", "err", err)
		}
	}

	trigger := pushTrigger(triggerRepo, push, oldSha, newSha)
	pipelineId, err := s.runPipeline(ctx, models.PipelineId(tid.TID()), repo.RepoDid, trigger, changedFiles, repoCloneUri, repoPath, newSha.String(), nil, triggerRepo, false)
	if errors.Is(err, db.ErrBannedSubject) {
		l.Warn("dropping push for banned repo")
		return "", nil
	}
	if err != nil {
		return "", err
	}
	if pipelineId == "" {
		l.Info("no workflow matched 'push' trigger, skipping the event")
		return "", nil
	}
	l.Info("pipeline triggered", "pipeline", pipelineId)
	return pipelineId, nil
}

// the lock covers only db reads and the webhook since a fetch can hang on a knot
func (s *Spindle) admitPush(l *slog.Logger, repo *db.Repo, push *org_tangled.EventPush, oldSha, newSha knotfeed.ObjectID, fire func()) (id models.PipelineId, done bool, err error) {
	defer s.lockRepo(repo.RepoDid).Unlock()

	if served, err := s.servedRepo(repo.RepoDid); err != nil {
		return "", true, fmt.Errorf("looking up repo %s: %w", repo.RepoDid, err)
	} else if served == nil {
		l.Info("repo went away before its push ran, dropping the push")
		return "", true, nil
	}
	// webhook fires on the first record, even if the push fails later
	if first, err := s.db.RecordPush(repo.RepoDid, push.Ref, oldSha.String(), newSha.String(), time.Now()); err != nil {
		return "", true, fmt.Errorf("recording the push: %w", err)
	} else if first {
		fire()
	}
	if existing, found, err := s.db.PushPipeline(repo.RepoDid.String(), push.Ref, oldSha.String(), newSha.String()); err != nil {
		return "", true, fmt.Errorf("looking up the push: %w", err)
	} else if found {
		l.Info("push already has a pipeline, skipping the replay", "pipeline", existing)
		return existing, true, nil
	}
	// skip ci only skips the pipeline, the webhook already fired
	if hasSkipCIPushOption(push.PushOptions) {
		l.Info("push requested ci skip, skipping the event")
		return "", true, nil
	}
	return "", false, nil
}

func hasSkipCIPushOption(pushOptions []string) bool {
	for _, opt := range pushOptions {
		switch opt {
		case "skip-ci", "ci-skip":
			return true
		}
	}
	return false
}

func pushTrigger(repo *workflow.TriggerRepo, push *org_tangled.EventPush, oldSha, newSha knotfeed.ObjectID) workflow.TriggerMetadata {
	return workflow.TriggerMetadata{
		Kind: string(workflow.TriggerKindPush),
		Push: &workflow.PushTriggerData{
			Ref:    push.Ref,
			OldSha: oldSha.String(),
			NewSha: newSha.String(),
		},
		Repo:  repo,
		Actor: push.Actor,
	}
}

func isMaterializedRef(refname string) bool {
	return strings.HasPrefix(refname, "refs/heads/") || strings.HasPrefix(refname, "refs/tags/")
}

// unknown changes run the path-gated workflows too, since missing ci is worse
func changedFilesUnderBudget(ctx context.Context, l *slog.Logger, repoPath string, oldSha, newSha knotfeed.ObjectID) (files []string, known bool) {
	if oldSha.IsZero() {
		return nil, false
	}
	paths, err := changedFiles(ctx, repoPath, oldSha, newSha)
	if err != nil {
		l.Warn("changed files unavailable between revisions", "oldSha", oldSha, "newSha", newSha, "err", err)
		return nil, false
	}
	admitted, ok := admitChangedFiles(paths)
	if !ok {
		l.Info("too many changed files to filter by path, running the workflows that have a paths filter", "oldSha", oldSha, "newSha", newSha, "files", len(paths))
		return nil, false
	}
	return admitted, true
}

// sync is shallow so oldSha may be missing, and go-git can't read sha256 repos
func changedFiles(ctx context.Context, repoPath string, oldSha, newSha knotfeed.ObjectID) ([]string, error) {
	diff := func() ([]byte, error) {
		return gitutil.Output(ctx, "-C", repoPath, "diff", "--name-only", "--no-renames", "-z", oldSha.String(), newSha.String())
	}
	out, err := diff()
	if err != nil {
		if fetchErr := gitutil.Run(ctx, "-C", repoPath, "fetch", "--depth=1", "--filter=tree:0", "origin", oldSha.String()); fetchErr != nil {
			return nil, fmt.Errorf("fetching the old commit: %w", fetchErr)
		}
		if out, err = diff(); err != nil {
			return nil, err
		}
	}
	return strings.FieldsFunc(string(out), func(r rune) bool { return r == 0 }), nil
}

func admitChangedFiles(paths []string) (admitted []string, ok bool) {
	spent := 0
	admitted = make([]string, 0, min(len(paths), changedFilesMaxCount))
	for _, path := range paths {
		spent += changedFileOverheadBytes + len(path)
		if spent > changedFilesMaxBytes || len(admitted) == changedFilesMaxCount {
			return nil, false
		}
		admitted = append(admitted, path)
	}
	return admitted, true
}