Monorepo for Tangled tangled.org

spindle,cmd/spindle: init spindle server

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

anirudh.fi 55ef369a 9d70af5e

verified
Changed files
+345
cmd
spindle
spindle
+14
cmd/spindle/main.go
··· 1 1 package main 2 2 3 + import ( 4 + "context" 5 + "os" 6 + 7 + "tangled.sh/tangled.sh/core/log" 8 + "tangled.sh/tangled.sh/core/spindle" 9 + ) 10 + 3 11 func main() { 12 + ctx := log.NewContext(context.Background(), "spindle") 13 + err := spindle.Run(ctx) 14 + if err != nil { 15 + log.FromContext(ctx).Error("error running spindle", "error", err) 16 + os.Exit(-1) 17 + } 4 18 }
+28
spindle/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/sethvargo/go-envconfig" 7 + ) 8 + 9 + type Server struct { 10 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 11 + DBPath string `env:"DB_PATH, default=spindle.db"` 12 + Hostname string `env:"HOSTNAME, required"` 13 + JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 14 + } 15 + 16 + type Config struct { 17 + Server Server `env:",prefix=SPINDLE_SERVER_"` 18 + } 19 + 20 + func Load(ctx context.Context) (*Config, error) { 21 + var cfg Config 22 + err := envconfig.Process(ctx, &cfg) 23 + if err != nil { 24 + return nil, err 25 + } 26 + 27 + return &cfg, nil 28 + }
+56
spindle/db/db.go
··· 1 + package db 2 + 3 + import "database/sql" 4 + 5 + type DB struct { 6 + *sql.DB 7 + } 8 + 9 + func Make(dbPath string) (*DB, error) { 10 + db, err := sql.Open("sqlite3", dbPath) 11 + if err != nil { 12 + return nil, err 13 + } 14 + 15 + _, err = db.Exec(` 16 + pragma journal_mode = WAL; 17 + pragma synchronous = normal; 18 + pragma foreign_keys = on; 19 + pragma temp_store = memory; 20 + pragma mmap_size = 30000000000; 21 + pragma page_size = 32768; 22 + pragma auto_vacuum = incremental; 23 + pragma busy_timeout = 5000; 24 + 25 + create table if not exists known_dids ( 26 + did text primary key 27 + ); 28 + 29 + create table if not exists pipelines ( 30 + rkey text not null, 31 + pipeline text not null, -- json 32 + primary key rkey 33 + ); 34 + `) 35 + if err != nil { 36 + return nil, err 37 + } 38 + 39 + return &DB{db}, nil 40 + } 41 + 42 + func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 43 + _, err := d.Exec(` 44 + insert into _jetstream (id, last_time_us) 45 + values (1, ?) 46 + on conflict(id) do update set last_time_us = excluded.last_time_us 47 + `, lastTimeUs) 48 + return err 49 + } 50 + 51 + func (d *DB) GetLastTimeUs() (int64, error) { 52 + var lastTimeUs int64 53 + row := d.QueryRow(`select last_time_us from _jetstream where id = 1;`) 54 + err := row.Scan(&lastTimeUs) 55 + return lastTimeUs, err 56 + }
+83
spindle/db/pipelines.go
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + type Pipeline struct { 8 + Rkey string 9 + PipelineJson string 10 + } 11 + 12 + func (d *DB) InsertPipeline(pipeline Pipeline) error { 13 + _, err := d.Exec( 14 + `insert into pipelines (rkey, nsid, event) values (?, ?, ?)`, 15 + pipeline.Rkey, 16 + pipeline.PipelineJson, 17 + ) 18 + 19 + return err 20 + } 21 + 22 + func (d *DB) GetPipeline(rkey, cursor string) (Pipeline, error) { 23 + whereClause := "where rkey = ?" 24 + args := []any{rkey} 25 + 26 + if cursor != "" { 27 + whereClause += " and rkey > ?" 28 + args = append(args, cursor) 29 + } 30 + 31 + query := fmt.Sprintf(` 32 + select rkey, pipeline 33 + from pipelines 34 + %s 35 + limit 1 36 + `, whereClause) 37 + 38 + row := d.QueryRow(query, args...) 39 + 40 + var p Pipeline 41 + err := row.Scan(&p.Rkey, &p.PipelineJson) 42 + if err != nil { 43 + return Pipeline{}, err 44 + } 45 + 46 + return p, nil 47 + } 48 + 49 + func (d *DB) GetPipelines(cursor string) ([]Pipeline, error) { 50 + whereClause := "" 51 + args := []any{} 52 + if cursor != "" { 53 + whereClause = "where rkey > ?" 54 + args = append(args, cursor) 55 + } 56 + 57 + query := fmt.Sprintf(` 58 + select rkey, nsid, pipeline 59 + from pipelines 60 + %s 61 + order by rkey asc 62 + limit 100 63 + `, whereClause) 64 + 65 + rows, err := d.Query(query, args...) 66 + if err != nil { 67 + return nil, err 68 + } 69 + defer rows.Close() 70 + 71 + var evts []Pipeline 72 + for rows.Next() { 73 + var ev Pipeline 74 + rows.Scan(&ev.Rkey, &ev.PipelineJson) 75 + evts = append(evts, ev) 76 + } 77 + 78 + if err := rows.Err(); err != nil { 79 + return nil, err 80 + } 81 + 82 + return evts, nil 83 + }
+71
spindle/server.go
··· 1 + package spindle 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + 8 + "golang.org/x/net/context" 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/jetstream" 11 + "tangled.sh/tangled.sh/core/knotserver/notifier" 12 + "tangled.sh/tangled.sh/core/log" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/config" 15 + "tangled.sh/tangled.sh/core/spindle/db" 16 + ) 17 + 18 + type Spindle struct { 19 + jc *jetstream.JetstreamClient 20 + db *db.DB 21 + e *rbac.Enforcer 22 + l *slog.Logger 23 + n *notifier.Notifier 24 + } 25 + 26 + func Run(ctx context.Context) error { 27 + cfg, err := config.Load(ctx) 28 + if err != nil { 29 + return fmt.Errorf("failed to load config: %w", err) 30 + } 31 + 32 + d, err := db.Make(cfg.Server.DBPath) 33 + if err != nil { 34 + return fmt.Errorf("failed to setup db: %w", err) 35 + } 36 + 37 + e, err := rbac.NewEnforcer(cfg.Server.DBPath) 38 + if err != nil { 39 + return fmt.Errorf("failed to setup rbac enforcer: %w", err) 40 + } 41 + 42 + logger := log.FromContext(ctx) 43 + 44 + collections := []string{tangled.SpindleMemberNSID} 45 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, false) 46 + if err != nil { 47 + return fmt.Errorf("failed to setup jetstream client: %w", err) 48 + } 49 + 50 + n := notifier.New() 51 + 52 + spindle := Spindle{ 53 + jc: jc, 54 + e: e, 55 + db: d, 56 + l: logger, 57 + n: &n, 58 + } 59 + 60 + logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 61 + logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router())) 62 + 63 + return nil 64 + } 65 + 66 + func (s *Spindle) Router() http.Handler { 67 + mux := &http.ServeMux{} 68 + 69 + mux.HandleFunc("/events", s.Events) 70 + return mux 71 + }
+93
spindle/stream.go
··· 1 + package spindle 2 + 3 + import ( 4 + "net/http" 5 + "time" 6 + 7 + "github.com/gorilla/websocket" 8 + "golang.org/x/net/context" 9 + ) 10 + 11 + var upgrader = websocket.Upgrader{ 12 + ReadBufferSize: 1024, 13 + WriteBufferSize: 1024, 14 + } 15 + 16 + func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 17 + l := s.l.With("handler", "Events") 18 + l.Info("received new connection") 19 + 20 + conn, err := upgrader.Upgrade(w, r, nil) 21 + if err != nil { 22 + l.Error("websocket upgrade failed", "err", err) 23 + w.WriteHeader(http.StatusInternalServerError) 24 + return 25 + } 26 + defer conn.Close() 27 + l.Info("upgraded http to wss") 28 + 29 + ch := s.n.Subscribe() 30 + defer s.n.Unsubscribe(ch) 31 + 32 + ctx, cancel := context.WithCancel(r.Context()) 33 + defer cancel() 34 + go func() { 35 + for { 36 + if _, _, err := conn.NextReader(); err != nil { 37 + l.Error("failed to read", "err", err) 38 + cancel() 39 + return 40 + } 41 + } 42 + }() 43 + 44 + cursor := "" 45 + 46 + // complete backfill first before going to live data 47 + l.Info("going through backfill", "cursor", cursor) 48 + if err := s.streamPipelines(conn, &cursor); err != nil { 49 + l.Error("failed to backfill", "err", err) 50 + return 51 + } 52 + 53 + for { 54 + // wait for new data or timeout 55 + select { 56 + case <-ctx.Done(): 57 + l.Info("stopping stream: client closed connection") 58 + return 59 + case <-ch: 60 + // we have been notified of new data 61 + l.Info("going through live data", "cursor", cursor) 62 + if err := s.streamPipelines(conn, &cursor); err != nil { 63 + l.Error("failed to stream", "err", err) 64 + return 65 + } 66 + case <-time.After(30 * time.Second): 67 + // send a keep-alive 68 + l.Info("sent keepalive") 69 + if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 70 + l.Error("failed to write control", "err", err) 71 + } 72 + } 73 + } 74 + } 75 + 76 + func (s *Spindle) streamPipelines(conn *websocket.Conn, cursor *string) error { 77 + ops, err := s.db.GetPipelines(*cursor) 78 + if err != nil { 79 + s.l.Debug("err", "err", err) 80 + return err 81 + } 82 + s.l.Debug("ops", "ops", ops) 83 + 84 + for _, op := range ops { 85 + if err := conn.WriteJSON(op); err != nil { 86 + s.l.Debug("err", "err", err) 87 + return err 88 + } 89 + *cursor = op.Rkey 90 + } 91 + 92 + return nil 93 + }