eventlog.go

  1// Package eventlog is a Tangled-compatible event stream: an SQLite log of
  2// {rkey, nsid, event, created} served on a websocket as upstream's
  3// eventstream does (knot and spindle /events). created is a monotonic local
  4// clock (UnixNano) and is the cursor: GET /events?cursor=N replays events
  5// with created > N as JSON text messages, then tails.
  6//
  7// The bridge serves sh.tangled.git.refUpdate on it; the runner serves
  8// sh.tangled.pipeline, sh.tangled.pipeline.status and run.rtw.ci.* records.
  9package eventlog
 10
 11import (
 12	"context"
 13	"crypto/subtle"
 14	"database/sql"
 15	"encoding/json"
 16	"fmt"
 17	"net/http"
 18	"strconv"
 19	"sync"
 20	"time"
 21
 22	"github.com/gorilla/websocket"
 23	_ "modernc.org/sqlite"
 24
 25	"git.kilimanjaro.io/ci/internal/lex"
 26	"git.kilimanjaro.io/ci/internal/tid"
 27)
 28
 29const (
 30	batchSize         = 500
 31	keepAliveInterval = 30 * time.Second
 32	writeDeadline     = 10 * time.Second
 33)
 34
 35// Log is an append-only event log.
 36type Log struct {
 37	db *sql.DB
 38
 39	mu   sync.Mutex
 40	last int64
 41	subs map[chan struct{}]struct{}
 42}
 43
 44// Open opens (or creates) the log at path.
 45func Open(path string) (*Log, error) {
 46	db, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)")
 47	if err != nil {
 48		return nil, err
 49	}
 50	db.SetMaxOpenConns(1) // one writer; reads are fast and serialised with it
 51	if _, err := db.Exec(`create table if not exists events (
 52		created integer primary key,
 53		rkey text not null,
 54		nsid text not null,
 55		event text not null
 56	)`); err != nil {
 57		db.Close()
 58		return nil, err
 59	}
 60	l := &Log{db: db, subs: map[chan struct{}]struct{}{}}
 61	if err := db.QueryRow(`select coalesce(max(created), 0) from events`).Scan(&l.last); err != nil {
 62		db.Close()
 63		return nil, err
 64	}
 65	return l, nil
 66}
 67
 68// DB exposes the database for packages that keep their own tables next to
 69// the log (the runner's cursor and pipelines).
 70func (l *Log) DB() *sql.DB { return l.db }
 71
 72// Close closes the database.
 73func (l *Log) Close() error { return l.db.Close() }
 74
 75// Append adds a record (marshalled to JSON) with a new TID rkey unless rkey
 76// is given, and wakes subscribers.
 77func (l *Log) Append(nsid, rkey string, record any) (lex.Event, error) {
 78	b, err := json.Marshal(record)
 79	if err != nil {
 80		return lex.Event{}, err
 81	}
 82	if rkey == "" {
 83		rkey = tid.New()
 84	}
 85	l.mu.Lock()
 86	created := time.Now().UnixNano()
 87	if created <= l.last {
 88		created = l.last + 1
 89	}
 90	_, err = l.db.Exec(`insert into events (created, rkey, nsid, event) values (?, ?, ?, ?)`, created, rkey, nsid, string(b))
 91	if err == nil {
 92		l.last = created
 93	}
 94	subs := make([]chan struct{}, 0, len(l.subs))
 95	for c := range l.subs {
 96		subs = append(subs, c)
 97	}
 98	l.mu.Unlock()
 99	if err != nil {
100		return lex.Event{}, err
101	}
102	for _, c := range subs {
103		select {
104		case c <- struct{}{}:
105		default:
106		}
107	}
108	return lex.Event{Rkey: rkey, Nsid: nsid, Event: b, Created: created}, nil
109}
110
111// List returns up to limit events after cursor, oldest first.
112func (l *Log) List(cursor int64, limit int) ([]lex.Event, error) {
113	rows, err := l.db.Query(`select created, rkey, nsid, event from events where created > ? order by created limit ?`, cursor, limit)
114	if err != nil {
115		return nil, err
116	}
117	defer rows.Close()
118	var out []lex.Event
119	for rows.Next() {
120		var ev lex.Event
121		var body string
122		if err := rows.Scan(&ev.Created, &ev.Rkey, &ev.Nsid, &body); err != nil {
123			return nil, err
124		}
125		ev.Event = json.RawMessage(body)
126		out = append(out, ev)
127	}
128	return out, rows.Err()
129}
130
131// subscribe returns a channel poked after every append.
132func (l *Log) subscribe() (chan struct{}, func()) {
133	c := make(chan struct{}, 1)
134	l.mu.Lock()
135	l.subs[c] = struct{}{}
136	l.mu.Unlock()
137	return c, func() {
138		l.mu.Lock()
139		delete(l.subs, c)
140		l.mu.Unlock()
141	}
142}
143
144var upgrader = websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 1024}
145
146// ServeHTTP serves the stream (GET /events?cursor=N).
147func (l *Log) ServeHTTP(w http.ResponseWriter, r *http.Request) {
148	var cursor int64
149	if raw := r.URL.Query().Get("cursor"); raw != "" {
150		c, err := strconv.ParseInt(raw, 10, 64)
151		if err != nil {
152			http.Error(w, "invalid cursor", http.StatusBadRequest)
153			return
154		}
155		cursor = c
156	}
157	conn, err := upgrader.Upgrade(w, r, nil)
158	if err != nil {
159		return
160	}
161	defer conn.Close()
162	poke, unsubscribe := l.subscribe()
163	defer unsubscribe()
164
165	ctx, cancel := context.WithCancel(r.Context())
166	defer cancel()
167	go func() {
168		for {
169			if _, _, err := conn.NextReader(); err != nil {
170				cancel()
171				return
172			}
173		}
174	}()
175	drain := func() error {
176		for {
177			evs, err := l.List(cursor, batchSize)
178			if err != nil {
179				return err
180			}
181			for _, ev := range evs {
182				b, _ := json.Marshal(ev)
183				_ = conn.SetWriteDeadline(time.Now().Add(writeDeadline))
184				if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
185					return err
186				}
187				cursor = ev.Created
188			}
189			if len(evs) < batchSize {
190				return nil
191			}
192		}
193	}
194	if drain() != nil {
195		return
196	}
197	for {
198		select {
199		case <-ctx.Done():
200			return
201		case <-poke:
202			if drain() != nil {
203				return
204			}
205		case <-time.After(keepAliveInterval):
206			if conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeDeadline)) != nil {
207				return
208			}
209		}
210	}
211}
212
213// Subscribe follows a remote stream from cursor, calling f for each event in
214// order, reconnecting with backoff until ctx ends. f's error stops the
215// subscription (return it to retry later from the last good cursor). token,
216// if set, is sent as a bearer token.
217func Subscribe(ctx context.Context, url string, cursor int64, token string, f func(lex.Event) error) error {
218	backoff := time.Second
219	for {
220		err := follow(ctx, url, &cursor, token, f)
221		if ctx.Err() != nil {
222			return ctx.Err()
223		}
224		if fe, ok := err.(handlerError); ok {
225			return fe.err
226		}
227		select {
228		case <-ctx.Done():
229			return ctx.Err()
230		case <-time.After(backoff):
231		}
232		backoff = min(backoff*2, time.Minute)
233	}
234}
235
236type handlerError struct{ err error }
237
238func (h handlerError) Error() string { return h.err.Error() }
239
240func follow(ctx context.Context, url string, cursor *int64, token string, f func(lex.Event) error) error {
241	h := http.Header{}
242	if token != "" {
243		h.Set("Authorization", "Bearer "+token)
244	}
245	conn, resp, err := websocket.DefaultDialer.DialContext(ctx, fmt.Sprintf("%s?cursor=%d", url, *cursor), h)
246	if err != nil {
247		if resp != nil {
248			return fmt.Errorf("%s: %s", url, resp.Status)
249		}
250		return err
251	}
252	defer conn.Close()
253	go func() {
254		<-ctx.Done()
255		conn.Close()
256	}()
257	for {
258		_, msg, err := conn.ReadMessage()
259		if err != nil {
260			return err
261		}
262		var ev lex.Event
263		if err := json.Unmarshal(msg, &ev); err != nil {
264			return err
265		}
266		if err := f(ev); err != nil {
267			return handlerError{err}
268		}
269		*cursor = ev.Created
270	}
271}
272
273// RequireToken wraps h so that requests must carry "Authorization: Bearer
274// <token>". An empty token disables the check.
275func RequireToken(token string, h http.Handler) http.Handler {
276	if token == "" {
277		return h
278	}
279	want := []byte("Bearer " + token)
280	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
281		if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), want) != 1 {
282			http.Error(w, "unauthorized", http.StatusUnauthorized)
283			return
284		}
285		h.ServeHTTP(w, r)
286	})
287}