+14
cmd/spindle/main.go
+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
+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
+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
+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
+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
+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
+
}