~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
384 lines · 10.2 KB Raw History
package eventstream

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"slices"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/ipfs/go-cid"

	v1alpha1 "github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/pdsclient"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/events"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/events/delivery"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/debounce"
)

const (
	maxWatchedRecords = 512

	maxDeletedRecords = 256

	maxGoneNoted = 1024
)

var ErrMatchPending = errors.New("eventstream: the record watch match is not resolved")

type MatchesFunc func() ([]Match, bool)

type Pending struct {
	Records map[string]v1alpha1.WatchedRecord

	Deletes map[string]struct{}

	Cursors map[string]int64
}

func newPending() Pending {
	return Pending{Records: map[string]v1alpha1.WatchedRecord{}, Deletes: map[string]struct{}{}, Cursors: map[string]int64{}}
}

type Sink struct {
	w *Watches

	name string

	matches MatchesFunc

	flush *debounce.Debounce

	progress *debounce.Debounce

	mu sync.Mutex

	pending Pending

	gone map[string]struct{}

	rkeys map[string]struct{}
}

var _ events.Sink = (*Sink)(nil)

func (w *Watches) NewSink(name string, matches MatchesFunc, flushed func()) *Sink {
	s := &Sink{w: w, name: strings.TrimSpace(name), matches: matches, pending: newPending(), gone: map[string]struct{}{}}
	s.flush = debounce.New(w.flushDelay, flushed)
	s.progress = debounce.New(delivery.ProgressDelay, flushed)
	return s
}

func (s *Sink) MarkProgress() {
	s.progress.Mark()
}

func (s *Sink) SetRkeys(rkeys []string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.rkeys = rkeySet(rkeys)
}

func (s *Sink) wants(rkey string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.rkeys == nil {
		return true
	}
	_, ok := s.rkeys[rkey]
	return ok
}

func rkeySet(rkeys []string) map[string]struct{} {
	var out map[string]struct{}
	for _, r := range rkeys {
		if r = strings.TrimSpace(r); r != "" {
			if out == nil {
				out = map[string]struct{}{}
			}
			out[r] = struct{}{}
		}
	}
	return out
}

func KeepRkeys(status v1alpha1.AtprotoRecordWatchStatus, rkeys []string) v1alpha1.AtprotoRecordWatchStatus {
	set := rkeySet(rkeys)
	if set == nil {
		return status
	}
	keep := func(uri string) bool {
		parsed, err := syntax.ParseATURI(strings.TrimSpace(uri))
		if err != nil {
			return false
		}
		_, ok := set[parsed.RecordKey().String()]
		return ok
	}
	status.Records = slices.DeleteFunc(slices.Clone(status.Records), func(r v1alpha1.WatchedRecord) bool { return !keep(r.URI) })
	status.Deleted = slices.DeleteFunc(slices.Clone(status.Deleted), func(uri string) bool { return !keep(uri) })
	return status
}

func (s *Sink) Deliver(ctx context.Context, event events.Event) error {
	repo, err := syntax.ParseDID(event.Repo)
	if err != nil {
		return fmt.Errorf("eventstream: the event repo %q is not a DID: %w", event.Repo, err)
	}
	var matches []Match
	if slices.ContainsFunc(event.Ops, func(op events.Op) bool { return op.Action == "create" || op.Action == "update" }) {
		var ok bool
		if matches, ok = s.matches(); !ok {
			return ErrMatchPending
		}
	}
	now := time.Now().UTC().Format(time.RFC3339Nano)
	records := map[string]v1alpha1.WatchedRecord{}
	deletes := map[string]struct{}{}
	for _, op := range event.Ops {
		collection, rkey, ok := splitPath(op.Path)
		if !ok || !s.wants(rkey) {
			continue
		}
		uri := "at://" + event.Repo + "/" + collection + "/" + rkey
		switch strings.ToLower(op.Action) {
		case "create", "update":
			var expected *cid.Cid
			if op.CID != "" {
				parsed, err := cid.Decode(op.CID)
				if err != nil {
					s.w.log.Warn("a watched record op has an invalid cid", "uri", uri, "cid", op.CID)
					continue
				}
				expected = &parsed
			}
			path := pdsclient.RepoPath{Repo: repo, Collection: syntax.NSID(collection), Rkey: syntax.RecordKey(rkey)}
			record, err := s.w.getWatchedRecord(ctx, repo, path, expected)
			if err != nil {
				if recordGone(err) {
					s.noteGone(uri, op.CID, err)
					continue
				}
				s.w.log.Warn("fetching a watched record", "uri", uri, "err", err.Error())
				continue
			}
			s.mu.Lock()
			delete(s.gone, uri)
			s.mu.Unlock()
			if record.URI.String() != uri {
				s.w.log.Warn("a watched record came back under another URI", "uri", uri, "got", record.URI.String())
				continue
			}
			if expected != nil && record.CID != *expected {
				s.w.log.Warn("a watched record came back with another CID", "uri", uri, "want", op.CID, "got", record.CID.String())
				continue
			}
			value := record.Value
			if !json.Valid(value) {
				value = json.RawMessage(`{}`)
			}
			if len(matches) > 0 && !MatchesRecord(value, matches) {
				continue
			}
			records[uri] = v1alpha1.WatchedRecord{Did: event.Repo, URI: uri, Cid: record.CID.String(), Value: value, VerifiedAt: now}
		case "delete":
			deletes[uri] = struct{}{}
		}
	}
	s.mu.Lock()
	for uri, record := range records {
		s.pending.Records[uri] = record
		delete(s.pending.Deletes, uri)
	}
	for uri := range deletes {
		delete(s.pending.Records, uri)
		s.pending.Deletes[uri] = struct{}{}
	}
	if event.Source != "" {
		s.pending.Cursors[event.Source] = event.Seq
	}
	s.mu.Unlock()
	if len(records) == 0 && len(deletes) == 0 {
		s.progress.Mark()
		return nil
	}
	s.flush.Mark()
	return nil
}

func recordGone(err error) bool {
	var xrpc *pdsclient.XRPCError
	return errors.As(err, &xrpc) && (xrpc.Code == "RecordNotFound" || xrpc.Code == "RepoNotFound" || xrpc.StatusCode == http.StatusNotFound)
}

func (s *Sink) noteGone(uri, cid string, err error) {
	s.mu.Lock()
	_, noted := s.gone[uri]
	if !noted {
		if len(s.gone) >= maxGoneNoted {
			clear(s.gone)
		}
		s.gone[uri] = struct{}{}
	}
	s.mu.Unlock()
	if noted {
		return
	}
	s.w.log.Info("a watched record op names a record its PDS no longer holds; the op is skipped", "watch", s.name, "uri", uri, "cid", cid,
		"err", err.Error())
}

func (s *Sink) Take() Pending {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := s.pending
	s.pending = newPending()
	return out
}

func (s *Sink) Close() {
	s.flush.Stop()
	s.progress.Stop()
}

func NoteDeleted(deleted []string, uris []string) []string {
	out := slices.Clone(deleted)
	for _, uri := range uris {
		if !slices.Contains(out, uri) {
			out = append(out, uri)
		}
	}
	if len(out) > maxDeletedRecords {
		out = out[len(out)-maxDeletedRecords:]
	}
	return out
}

func MergeStatus(status v1alpha1.AtprotoRecordWatchStatus, p Pending) v1alpha1.AtprotoRecordWatchStatus {
	status.Records = slices.Clone(status.Records)
	status.Deleted = slices.Clone(status.Deleted)
	status.Cursors = slices.Clone(status.Cursors)
	for uri, record := range p.Records {
		i := slices.IndexFunc(status.Records, func(r v1alpha1.WatchedRecord) bool { return r.URI == uri })
		if i >= 0 {
			if prior := status.Records[i]; prior.Did == record.Did && prior.Cid == record.Cid && bytes.Equal(prior.Value, record.Value) {
				record.VerifiedAt = prior.VerifiedAt
			}
			status.Records[i] = record
		} else {
			status.Records = append(status.Records, record)
		}
	}
	for uri := range p.Deletes {
		status.Records = slices.DeleteFunc(status.Records, func(r v1alpha1.WatchedRecord) bool { return r.URI == uri })
		if !slices.Contains(status.Deleted, uri) {
			status.Deleted = append(status.Deleted, uri)
		}
	}
	if len(status.Deleted) > maxDeletedRecords {
		status.Deleted = status.Deleted[len(status.Deleted)-maxDeletedRecords:]
	}
	for source, cursor := range p.Cursors {
		i := slices.IndexFunc(status.Cursors, func(c v1alpha1.AtprotoRecordWatchCursor) bool { return c.Source == source })
		if i >= 0 {
			status.Cursors[i].Cursor = cursor
		} else {
			status.Cursors = append(status.Cursors, v1alpha1.AtprotoRecordWatchCursor{Source: source, Cursor: cursor})
		}
	}
	status.Records = boundWatchedRecords(status.Records)
	return status
}

func (w *Watches) getWatchedRecord(ctx context.Context, did syntax.DID, path pdsclient.RepoPath, expected *cid.Cid) (pdsclient.Record, error) {
	pds, err := w.authorPDS(ctx, did)
	if err != nil {
		return pdsclient.Record{}, err
	}
	record, err := pds.GetRecord(ctx, path, expected)
	if err == nil || !pdsEndpointStale(err) {
		return record, err
	}
	w.forgetAuthorPDS(ctx, did)
	resolved, resolveErr := w.authorPDS(ctx, did)
	if resolveErr != nil {
		return pdsclient.Record{}, err
	}
	return resolved.GetRecord(ctx, path, expected)
}

func (w *Watches) authorPDS(ctx context.Context, did syntax.DID) (pdsclient.PDS, error) {
	if pds, ok := w.pdsCache.Get(did); ok && pds != nil {
		return pds, nil
	}
	ident, err := w.directory.LookupDID(ctx, did)
	if err != nil {
		return nil, fmt.Errorf("eventstream: resolving %s: %w", did, err)
	}
	endpoint := strings.TrimSpace(ident.PDSEndpoint())
	if endpoint == "" {
		return nil, fmt.Errorf("eventstream: %s declares no atproto_pds service", did)
	}
	pds, err := pdsclient.NewRemote(pdsclient.RemoteConfig{BaseURL: endpoint, DID: did, HTTPClient: w.http, Timeout: 20 * time.Second})
	if err != nil {
		return nil, err
	}
	w.pdsCache.Set(did, pds)
	return pds, nil
}

func (w *Watches) forgetAuthorPDS(ctx context.Context, did syntax.DID) {
	w.pdsCache.Delete(did)
	_ = w.directory.Purge(ctx, did.AtIdentifier())
}

func pdsEndpointStale(err error) bool {
	var xrpc *pdsclient.XRPCError
	if !errors.As(err, &xrpc) {
		return false
	}
	if xrpc.Code == "RecordNotFound" {
		return false
	}
	switch xrpc.StatusCode {
	case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound:
		return true
	}
	return false
}

func boundWatchedRecords(records []v1alpha1.WatchedRecord) []v1alpha1.WatchedRecord {
	if len(records) <= maxWatchedRecords {
		return records
	}
	ordered := append([]v1alpha1.WatchedRecord(nil), records...)
	sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].VerifiedAt > ordered[j].VerifiedAt })
	return ordered[:maxWatchedRecords]
}

func toUnstructured(value any) (map[string]any, error) {
	raw, err := json.Marshal(value)
	if err != nil {
		return nil, err
	}
	object := map[string]any{}
	if err := json.Unmarshal(raw, &object); err != nil {
		return nil, err
	}
	return object, nil
}

func splitPath(path string) (string, string, bool) {
	collection, rkey, ok := strings.Cut(path, "/")
	if !ok || collection == "" || rkey == "" {
		return "", "", false
	}
	return collection, rkey, true
}