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

import (
	"cmp"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"
	cid "github.com/ipfs/go-cid"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"

	v1alpha1 "github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/market"
	"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/controllers/runtime/kube"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/events/relay"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/kcp"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/statesync"
)

const (
	marketBidderMarketInbox = "bidder.market-inbox"

	marketRequesterMarketInbox = "requester.market-inbox"

	marketRequesterBidInbox = "requester.bid-inbox"

	marketInboxLabel = "socialweb.computer/market-inbox"

	marketCollectionLabel = "socialweb.computer/market-collection"

	marketNamePrefix = "market-"

	MarketDedupWindow = 15 * time.Second

	MarketDedupCap = 10000
)

type DynamicFactory = kube.Factory

func newDirectory(httpClient *http.Client, plcURL, workspace string) identity.Directory {
	return directoryAt(httpClient, cmp.Or(strings.TrimSpace(plcURL), "http://"+kcp.ServiceFQDN("plc", "default", workspace, kcp.DefaultServiceDomain)))
}

type MarketOptions struct {
	Workspace string

	Role string

	Dynamic DynamicFactory

	Resolver *pdsclient.Resolver

	PLCDirectoryURL string

	Context context.Context

	Log *slog.Logger
}

func MarketHandlerName(role, workspace string) string {
	return marketNamePrefix + strings.ToLower(strings.TrimSpace(role)) + "@" + strings.TrimSpace(workspace)
}

type marketHandler struct {
	opts MarketOptions

	log *slog.Logger

	directory identity.Directory

	client *http.Client

	mu    sync.Mutex
	pdses map[syntax.DID]pdsclient.PDS

	seen *seenSet

	addressees *addressees
}

type marketPayload struct {
	DID string `json:"did"`

	URI string `json:"uri"`

	CID string `json:"cid"`

	Collection string `json:"collection"`

	Record map[string]any `json:"record"`
}

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

func MarketRole(name string) (string, bool) {
	rest, ok := strings.CutPrefix(strings.TrimSpace(name), marketNamePrefix)
	if !ok {
		return "", false
	}
	role, _, _ := strings.Cut(rest, "@")
	switch role {
	case "bidder", "requester":
		return role, true
	}
	return "", false
}

type MarketSink = marketHandler

func NewMarketSink(opts MarketOptions) (*MarketSink, error) {
	opts.Workspace = strings.TrimSpace(opts.Workspace)
	if opts.Workspace == "" {
		return nil, errors.New("eventstream: a market handler needs a workspace")
	}
	opts.Role = strings.ToLower(strings.TrimSpace(opts.Role))
	switch opts.Role {
	case "bidder", "requester":
	default:
		return nil, fmt.Errorf("eventstream: unknown market role %q", opts.Role)
	}
	if opts.Dynamic == nil {
		return nil, errors.New("eventstream: a market handler needs a dynamic client factory")
	}
	if opts.Resolver == nil {
		return nil, errors.New("eventstream: a market handler needs a pdsclient resolver")
	}
	if opts.Log == nil {
		opts.Log = slog.New(slog.DiscardHandler)
	}
	if opts.Context == nil {
		return nil, errors.New("eventstream: a market handler needs the run context")
	}
	addressees, err := newAddressees(opts.Context, opts.Role, opts.Workspace, opts.Dynamic, opts.Log)
	if err != nil {
		return nil, fmt.Errorf("eventstream: the market addressees of %s: %w", opts.Workspace, err)
	}
	httpClient := httpClientWithFallback(opts.Resolver.HTTPClient, nil)
	return &marketHandler{
		opts:       opts,
		log:        opts.Log,
		directory:  newDirectory(httpClient, opts.PLCDirectoryURL, opts.Workspace),
		client:     httpClient,
		pdses:      map[syntax.DID]pdsclient.PDS{},
		seen:       newSeenSet(MarketDedupWindow, MarketDedupCap),
		addressees: addressees,
	}, nil
}

func (h *marketHandler) 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)
	}
	for _, op := range event.Ops {
		switch strings.ToLower(strings.TrimSpace(op.Action)) {
		case "create", "update":
		default:
			continue
		}
		collection, rkey, ok := splitPath(op.Path)
		if !ok {
			continue
		}
		adapter := marketAdapter(h.opts.Role, collection)
		if adapter == "" {
			h.addressees.skipped(collection)
			continue
		}
		if !h.addressees.wants(ctx, collection, event.Repo) {
			if err := ctx.Err(); err != nil {
				return err
			}
			continue
		}
		uri := "at://" + event.Repo + "/" + collection + "/" + rkey
		if h.addressees.closed(event.Source, collection, uri, nil) {
			continue
		}
		if op.CID == "" {
			if err := h.deliverOp(ctx, event, adapter, repo, uri, collection, rkey, op); err != nil {
				return err
			}
			continue
		}
		if err := h.deliverOnce(ctx, event, adapter, repo, uri, collection, rkey, op); err != nil {
			return err
		}
	}
	return nil
}

func (h *marketHandler) deliverOnce(ctx context.Context, event events.Event, adapter string, repo syntax.DID, uri, collection, rkey string, op events.Op) error {
	key := uri + "|" + op.CID
	for {
		entry, owner := h.seen.claim(key, event.Source, time.Now())
		if owner {
			err := h.deliverOp(ctx, event, adapter, repo, uri, collection, rkey, op)
			h.seen.finish(key, entry, err == nil)
			return err
		}
		select {
		case <-entry.done:
		case <-ctx.Done():
			return ctx.Err()
		}
		if !entry.ok {
			continue
		}
		if dup := h.seen.drop(entry); dup.Drops == 1 {
			h.log.Info("market_event_duplicate_dropped", "role", h.opts.Role, "workspace", h.opts.Workspace, "uri", uri, "cid", op.CID,
				"source", event.Source, "first", dup.First, "dropped", dup.Dropped)
		}
		return nil
	}
}

func (h *marketHandler) deliverOp(ctx context.Context, event events.Event, adapter string, repo syntax.DID, uri, collection, rkey string, op events.Op) error {
	var expected *cid.Cid
	if op.CID != "" {
		parsed, err := cid.Decode(op.CID)
		if err != nil {
			return fmt.Errorf("eventstream: the market op %s has an invalid cid: %w", uri, err)
		}
		expected = &parsed
	}
	value, recordCID, err := h.fetchMarketRecord(ctx, event.Source, repo, collection, rkey, expected)
	if err != nil {
		return fmt.Errorf("eventstream: fetching the market record %s: %w", uri, err)
	}
	if h.addressees.closed(event.Source, collection, uri, value) {
		return nil
	}
	owner, ok := h.addressees.owner(ctx, collection, event.Repo, value, func(ctx context.Context, receiptURI string) (map[string]any, error) {
		return h.fetchReceipt(ctx, event.Source, receiptURI)
	})
	if !ok {
		return ctx.Err()
	}
	return h.createSync(ctx, adapter, event.Repo, uri, recordCID, collection, value, owner)
}

func (h *marketHandler) fetchReceipt(ctx context.Context, source, receiptURI string) (map[string]any, error) {
	aturi, err := syntax.ParseATURI(receiptURI)
	if err != nil {
		return nil, err
	}
	repo, err := syntax.ParseDID(aturi.Authority().String())
	if err != nil {
		return nil, err
	}
	value, _, err := h.fetchMarketRecord(ctx, source, repo, aturi.Collection().String(), aturi.RecordKey().String(), nil)
	return value, err
}

func (h *marketHandler) createSync(ctx context.Context, adapter, did, uri, cid, collection string, record map[string]any, owner *market.ContractRef) error {
	payload, err := json.Marshal(marketPayload{DID: did, URI: uri, CID: cid, Collection: collection, Record: record})
	if err != nil {
		return fmt.Errorf("eventstream: encoding the market payload for %s: %w", uri, err)
	}
	object := &v1alpha1.StateSync{
		TypeMeta: metav1.TypeMeta{APIVersion: v1alpha1.GroupVersion.String(), Kind: "StateSync"},
		ObjectMeta: metav1.ObjectMeta{
			Name: marketSyncName(uri, cid),
			Labels: map[string]string{
				marketInboxLabel:      "true",
				marketCollectionLabel: collection,
			},
			OwnerReferences: contractOwners(owner),
		},
		Spec: v1alpha1.StateSyncSpec{
			Sink:           v1alpha1.StateSyncSink{Adapter: adapter},
			Direction:      v1alpha1.StateSyncDirectionOutbound,
			Payload:        payload,
			IdempotencyKey: "market:" + uri + "#" + cid,
			ConflictPolicy: v1alpha1.StateSyncConflictSourceWins,
			DeletionPolicy: v1alpha1.StateSyncDeletionRetain,
		},
	}
	stored, err := toUnstructured(object)
	if err != nil {
		return fmt.Errorf("eventstream: encoding the market StateSync for %s: %w", uri, err)
	}
	dyn, err := h.opts.Dynamic(h.opts.Workspace)
	if err != nil {
		return fmt.Errorf("eventstream: opening workspace %s: %w", h.opts.Workspace, err)
	}
	if _, err := kube.Create[kube.Doc](ctx, dyn.Resource(statesync.StateSyncGVR), stored, metav1.CreateOptions{}); err != nil {
		if apierrors.IsAlreadyExists(err) {
			return nil
		}
		return fmt.Errorf("eventstream: creating the market StateSync %s: %w", object.Name, err)
	}
	return nil
}

func (h *marketHandler) fetchMarketRecord(ctx context.Context, source string, repo syntax.DID, collection, rkey string, cidPtr *cid.Cid) (map[string]any, string, error) {
	cidText := ""
	if cidPtr != nil {
		cidText = cidPtr.String()
	}
	if pds, err := h.authorPDS(ctx, repo); err == nil {
		record, err := pds.GetRecord(ctx, pdsclient.RepoPath{Repo: repo, Collection: syntax.NSID(collection), Rkey: syntax.RecordKey(rkey)}, cidPtr)
		if err == nil {
			value := map[string]any{}
			if err := json.Unmarshal(record.Value, &value); err != nil {
				return nil, "", err
			}
			return value, record.CID.String(), nil
		}
	}
	hostname, err := h.relayHost(ctx, source, repo.String())
	if err != nil {
		if hostname = sourceHost(source); hostname == "" {
			return nil, "", err
		}
	}
	for _, scheme := range []string{"http", "https"} {
		target := scheme + "://" + hostname + "/xrpc/com.atproto.repo.getRecord?repo=" + url.QueryEscape(repo.String()) +
			"&collection=" + url.QueryEscape(collection) + "&rkey=" + url.QueryEscape(rkey) + "&cid=" + url.QueryEscape(cidText)
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
		if err != nil {
			return nil, "", err
		}
		req.Header.Set("Accept", "application/json")
		resp, err := h.httpClient().Do(req)
		if err != nil {
			continue
		}
		data, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
		resp.Body.Close()
		if readErr != nil || resp.StatusCode != http.StatusOK {
			continue
		}
		var body struct {
			CID   string         `json:"cid"`
			Value map[string]any `json:"value"`
		}
		if err := json.Unmarshal(data, &body); err != nil {
			continue
		}
		if body.Value == nil {
			continue
		}
		body.CID = cmp.Or(body.CID, cidText)
		return body.Value, body.CID, nil
	}
	return nil, "", errors.New("the author PDS is not reachable through its identity or the relay host list")
}

func (h *marketHandler) relayHost(ctx context.Context, source, did string) (string, error) {
	base, ok := strings.CutPrefix(strings.TrimSpace(source), relay.SourcePrefix)
	if !ok || base == "" {
		return "", errors.New("no relay source names the author PDS")
	}
	target := strings.TrimRight(base, "/") + "/xrpc/com.atproto.sync.getRepoStatus?did=" + url.QueryEscape(did)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("Accept", "application/json")
	resp, err := h.httpClient().Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("relay getRepoStatus returned HTTP %d", resp.StatusCode)
	}
	var body struct {
		Hostname string `json:"hostname"`
	}
	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
		return "", err
	}
	if strings.TrimSpace(body.Hostname) == "" {
		return "", fmt.Errorf("relay getRepoStatus names no host for %s", did)
	}
	return strings.TrimSpace(body.Hostname), nil
}

func sourceHost(source string) string {
	base, ok := strings.CutPrefix(strings.TrimSpace(source), relay.SourcePrefix)
	if !ok {
		return ""
	}
	u, err := url.Parse(strings.TrimSpace(base))
	if err != nil {
		return ""
	}
	return u.Host
}

func (h *marketHandler) authorPDS(ctx context.Context, did syntax.DID) (pdsclient.PDS, error) {
	h.mu.Lock()
	cached := h.pdses[did]
	h.mu.Unlock()
	if cached != nil {
		return cached, nil
	}
	ident, err := h.directory.LookupDID(ctx, did)
	if err != nil {
		return nil, err
	}
	endpoint := strings.TrimSpace(ident.PDSEndpoint())
	if endpoint == "" {
		return nil, fmt.Errorf("%s declares no atproto_pds service", did)
	}
	pds, err := pdsclient.NewRemote(pdsclient.RemoteConfig{
		BaseURL:    endpoint,
		DID:        did,
		HTTPClient: h.httpClient(),
		Timeout:    20 * time.Second,
	})
	if err != nil {
		return nil, err
	}
	h.mu.Lock()
	h.pdses[did] = pds
	h.mu.Unlock()
	return pds, nil
}

func (h *marketHandler) httpClient() *http.Client {
	if h.client != nil {
		return h.client
	}
	return httpClientWithFallback(nil, nil)
}

func contractOwners(owner *market.ContractRef) []metav1.OwnerReference {
	if owner == nil || owner.Name == "" || owner.UID == "" {
		return nil
	}
	return []metav1.OwnerReference{{APIVersion: v1alpha1.GroupVersion.String(), Kind: "ComputeContract", Name: owner.Name, UID: types.UID(owner.UID)}}
}

func marketSyncName(uri, cid string) string {
	sum := sha256.Sum256([]byte(uri + "#" + cid))
	return marketNamePrefix + hex.EncodeToString(sum[:16])
}

func marketAdapter(role, collection string) string {
	switch role {
	case "bidder":
		if market.BidderConsumes(collection) {
			return marketBidderMarketInbox
		}
	case "requester":
		switch {
		case collection == market.BidNSID:
			return marketRequesterBidInbox
		case market.RequesterConsumes(collection):
			return marketRequesterMarketInbox
		}
	}
	return ""
}