CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 10. Simplified Sliding Sync (MSC4186) is served at the unstable org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists are sorted by activity and filtered by DM, invite, encryption, room type, space and tag; room subscriptions, required_state with wildcards, $ME and $LAZY, and the to_device, e2ee, account_data, receipts and typing extensions are supported. Per-connection state is kept in memory and an unknown pos answers M_UNKNOWN_POS. Threads and relations: m.relates_to is indexed on roomserver_events (migration 0012), /relations (with recursion) and /threads are served, and every event a timeline read returns carries its m.thread, m.replace and m.reference summaries in unsigned.m.relations. Notifications carry a thread ID (migration 0013): threaded receipts clear only their thread, and sync reports unread_thread_notifications when the filter asks. Spaces: /hierarchy walks a space breadth first, asking other servers about rooms this one is not in through the new federation hierarchy endpoint; room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase) or tsvector (postgres), bounded to what each user may see, with context, profiles, state, grouping and paging. The PocketBase driver is real: the sqlite schema and store on an embedded PocketBase app's database, with media in its filesystem, local or S3 (database.pocketbase.s3). It runs the full storage test suite. Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans per route, component spans, and trace propagation on federation, push and application service calls. New metrics for persisted events, waiting syncs, sliding sync connections and the database pool, with a Grafana dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana, Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports request and delivery latencies. The load tester found a long-standing bug, fixed here: on a server where no account data had been written, every incremental /sync re-sent the push rules and returned at once, and an all-zero token was taken for an initial sync, so long polls became busy loops. The PostgreSQL migrations and search dialect compile but were not run against a live PostgreSQL in this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
5.5 KiB
Go
174 lines
5.5 KiB
Go
package appservice
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/tracing"
|
|
)
|
|
|
|
// Errors of the Application Service API client. The ping endpoint reports
|
|
// each with its own Matrix error code.
|
|
var (
|
|
// ErrNoURL is a service registered without a URL, which cannot be
|
|
// sent anything.
|
|
ErrNoURL = errors.New("application service has no URL")
|
|
|
|
// ErrConnectionFailed means the service could not be reached.
|
|
ErrConnectionFailed = errors.New("could not connect to the application service")
|
|
|
|
// ErrConnectionTimeout means it did not answer in time.
|
|
ErrConnectionTimeout = errors.New("the application service did not answer in time")
|
|
)
|
|
|
|
// BadStatusError is an answer from the service that was not a success.
|
|
type BadStatusError struct {
|
|
Status int
|
|
|
|
// Body is the start of what the service answered, for whoever has to
|
|
// work out why.
|
|
Body string
|
|
}
|
|
|
|
func (e *BadStatusError) Error() string {
|
|
return fmt.Sprintf("the application service answered %d: %s", e.Status, e.Body)
|
|
}
|
|
|
|
// maxErrorBody caps how much of a failed answer is kept.
|
|
const maxErrorBody = 4 << 10
|
|
|
|
// Client speaks the Application Service API: the requests a homeserver
|
|
// makes to a service.
|
|
type Client struct {
|
|
http *http.Client
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewClient builds a client. The timeout bounds one request; transactions
|
|
// that time out are retried like any other failure.
|
|
func NewClient(timeout time.Duration) *Client {
|
|
return &Client{
|
|
http: &http.Client{
|
|
Transport: tracing.Transport(nil),
|
|
// A service is reached at the URL its registration names and
|
|
// nowhere else: a redirect would take the hs_token with it.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
},
|
|
timeout: timeout,
|
|
}
|
|
}
|
|
|
|
// transactionBody is PUT /_matrix/app/v1/transactions/{txnId}.
|
|
type transactionBody struct {
|
|
Events []json.RawMessage `json:"events"`
|
|
}
|
|
|
|
// SendTransaction delivers a batch of events. The same transaction ID is
|
|
// resent with the same events until the service accepts it, and the
|
|
// service deduplicates by ID.
|
|
func (c *Client) SendTransaction(ctx context.Context, svc *Service, txnID int64, events []json.RawMessage) error {
|
|
body, err := json.Marshal(transactionBody{Events: events})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = c.do(ctx, svc, http.MethodPut, "/_matrix/app/v1/transactions/"+strconv.FormatInt(txnID, 10), body)
|
|
return err
|
|
}
|
|
|
|
// QueryUser asks a service whether a user in its namespace exists, which
|
|
// it answers by creating the user if it wants it to. It reports whether
|
|
// the service said yes.
|
|
func (c *Client) QueryUser(ctx context.Context, svc *Service, userID string) (bool, error) {
|
|
return c.query(ctx, svc, "/_matrix/app/v1/users/"+url.PathEscape(userID))
|
|
}
|
|
|
|
// QueryAlias asks a service whether an alias in its namespace exists,
|
|
// which it answers by creating the room and the alias.
|
|
func (c *Client) QueryAlias(ctx context.Context, svc *Service, alias string) (bool, error) {
|
|
return c.query(ctx, svc, "/_matrix/app/v1/rooms/"+url.PathEscape(alias))
|
|
}
|
|
|
|
// query makes a GET whose answer is yes (200) or no (404).
|
|
func (c *Client) query(ctx context.Context, svc *Service, path string) (bool, error) {
|
|
_, err := c.do(ctx, svc, http.MethodGet, path, nil)
|
|
var status *BadStatusError
|
|
switch {
|
|
case err == nil:
|
|
return true, nil
|
|
case errors.As(err, &status) && status.Status == http.StatusNotFound:
|
|
return false, nil
|
|
default:
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
// Ping makes POST /_matrix/app/v1/ping and reports how long it took.
|
|
func (c *Client) Ping(ctx context.Context, svc *Service, transactionID string) (time.Duration, error) {
|
|
body, err := json.Marshal(map[string]string{"transaction_id": transactionID})
|
|
if transactionID == "" {
|
|
body, err = []byte(`{}`), nil
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
start := time.Now()
|
|
if _, err := c.do(ctx, svc, http.MethodPost, "/_matrix/app/v1/ping", body); err != nil {
|
|
return 0, err
|
|
}
|
|
return time.Since(start), nil
|
|
}
|
|
|
|
// do makes one request to a service, authenticated with its hs_token.
|
|
func (c *Client) do(ctx context.Context, svc *Service, method, path string, body []byte) ([]byte, error) {
|
|
if svc.URL == "" {
|
|
return nil, ErrNoURL
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
|
defer cancel()
|
|
|
|
var reader io.Reader
|
|
if body != nil {
|
|
reader = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(svc.URL, "/")+path, reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
|
|
}
|
|
// The header form only. The spec deprecated ?access_token= for this in
|
|
// v1.4, and a token in a URL ends up in the service's access log.
|
|
req.Header.Set("Authorization", "Bearer "+svc.HSToken)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
var netErr net.Error
|
|
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
|
|
return nil, fmt.Errorf("%w: %v", ErrConnectionTimeout, err)
|
|
}
|
|
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
head, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
|
|
return nil, &BadStatusError{Status: resp.StatusCode, Body: string(head)}
|
|
}
|
|
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
|
|
}
|
|
return out, nil
|
|
}
|