Monorepo for Tangled tangled.org
4

Configure Feed

Select the types of activity you want to include in your feed.

core / jetstream / jetstream.go
6.2 kB 273 lines
1package jetstream 2 3import ( 4 "context" 5 "fmt" 6 "log/slog" 7 "os" 8 "os/signal" 9 "sync" 10 "sync/atomic" 11 "syscall" 12 "time" 13 14 "github.com/bluesky-social/jetstream/pkg/client" 15 "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 16 "github.com/bluesky-social/jetstream/pkg/models" 17 "tangled.org/core/log" 18) 19 20type DB interface { 21 GetLastTimeUs() (int64, error) 22 SaveLastTimeUs(int64) error 23} 24 25type Set[T comparable] map[T]struct{} 26 27type JetstreamClient struct { 28 cfg *client.ClientConfig 29 client *client.Client 30 ident string 31 l *slog.Logger 32 33 logDids bool 34 wantedDids Set[string] 35 unfilteredNsids Set[string] 36 db DB 37 waitForDid bool 38 mu sync.RWMutex 39 40 lastSeenUs atomic.Int64 41 42 cancel context.CancelFunc 43 cancelMu sync.Mutex 44} 45 46func (j *JetstreamClient) AddDid(did string) { 47 if did == "" { 48 return 49 } 50 51 if j.logDids { 52 j.l.Info("adding did to in-memory filter", "did", did) 53 } 54 j.mu.Lock() 55 j.wantedDids[did] = struct{}{} 56 j.mu.Unlock() 57} 58 59func (j *JetstreamClient) ExemptCollection(nsid string) { 60 j.mu.Lock() 61 j.unfilteredNsids[nsid] = struct{}{} 62 j.mu.Unlock() 63} 64 65func (j *JetstreamClient) RemoveDid(did string) { 66 if did == "" { 67 return 68 } 69 70 if j.logDids { 71 j.l.Info("removing did from in-memory filter", "did", did) 72 } 73 j.mu.Lock() 74 delete(j.wantedDids, did) 75 j.mu.Unlock() 76} 77 78type processor func(context.Context, *models.Event) error 79 80func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 81 // since this closure references j.WantedDids; it should auto-update 82 // existing instances of the closure when j.WantedDids is mutated 83 return func(ctx context.Context, evt *models.Event) error { 84 j.mu.RLock() 85 // empty filter => all dids allowed 86 matches := len(j.wantedDids) == 0 87 if !matches { 88 if _, ok := j.wantedDids[evt.Did]; ok { 89 matches = true 90 } 91 } 92 if !matches && evt.Commit != nil { 93 if _, ok := j.unfilteredNsids[evt.Commit.Collection]; ok { 94 matches = true 95 } 96 } 97 j.mu.RUnlock() 98 99 var err error 100 if matches { 101 err = processFunc(ctx, evt) 102 } 103 104 j.lastSeenUs.Store(evt.TimeUS + 1) 105 return err 106 } 107} 108 109func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid, logDids bool) (*JetstreamClient, error) { 110 if cfg == nil { 111 cfg = client.DefaultClientConfig() 112 cfg.WebsocketURL = endpoint 113 cfg.WantedCollections = collections 114 } 115 116 return &JetstreamClient{ 117 cfg: cfg, 118 ident: ident, 119 db: db, 120 l: logger, 121 wantedDids: make(map[string]struct{}), 122 unfilteredNsids: make(map[string]struct{}), 123 124 logDids: logDids, 125 126 // This will make the goroutine in StartJetstream wait until 127 // j.wantedDids has been populated, typically using addDids. 128 waitForDid: waitForDid, 129 }, nil 130} 131 132// StartJetstream starts the jetstream client and processes events using the provided processFunc. 133// The client persists the last time_us cursor itself via the DB it was constructed with. 134func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error { 135 logger := j.l 136 137 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 138 139 client, err := client.NewClient(j.cfg, logger, sched) 140 if err != nil { 141 return fmt.Errorf("failed to create jetstream client: %w", err) 142 } 143 j.client = client 144 145 go func() { 146 if j.waitForDid { 147 for { 148 j.mu.RLock() 149 hasDid := len(j.wantedDids) != 0 150 j.mu.RUnlock() 151 if hasDid { 152 break 153 } 154 time.Sleep(time.Second) 155 } 156 } 157 logger.Info("done waiting for did") 158 159 go j.periodicLastTimeSave(ctx) 160 j.saveIfKilled(ctx) 161 162 j.connectAndRead(ctx) 163 }() 164 165 return nil 166} 167 168func (j *JetstreamClient) connectAndRead(ctx context.Context) { 169 l := log.FromContext(ctx) 170 for { 171 cursor := j.resumeCursor(ctx) 172 173 connCtx, cancel := context.WithCancel(ctx) 174 j.cancelMu.Lock() 175 j.cancel = cancel 176 j.cancelMu.Unlock() 177 178 if err := j.client.ConnectAndRead(connCtx, cursor); err != nil { 179 l.Error("error reading jetstream", "error", err) 180 cancel() 181 continue 182 } 183 184 select { 185 case <-ctx.Done(): 186 l.Info("context done, stopping jetstream") 187 return 188 case <-connCtx.Done(): 189 l.Info("connection context done, reconnecting") 190 continue 191 } 192 } 193} 194 195// save cursor periodically 196func (j *JetstreamClient) periodicLastTimeSave(ctx context.Context) { 197 ticker := time.NewTicker(time.Minute) 198 defer ticker.Stop() 199 200 for { 201 select { 202 case <-ctx.Done(): 203 return 204 case <-ticker.C: 205 if seen := j.lastSeenUs.Load(); seen != 0 { 206 if err := j.db.SaveLastTimeUs(seen); err != nil { 207 log.FromContext(ctx).Error("failed to save cursor", "error", err) 208 } 209 } 210 } 211 } 212} 213 214func (j *JetstreamClient) resumeCursor(ctx context.Context) *int64 { 215 if seen := j.lastSeenUs.Load(); seen != 0 { 216 return &seen 217 } 218 return j.getLastTimeUs(ctx) 219} 220 221func (j *JetstreamClient) getLastTimeUs(ctx context.Context) *int64 { 222 l := log.FromContext(ctx) 223 lastTimeUs, err := j.db.GetLastTimeUs() 224 if err != nil { 225 l.Warn("couldn't get last time us, starting from now", "error", err) 226 lastTimeUs = time.Now().UnixMicro() 227 if err = j.db.SaveLastTimeUs(lastTimeUs); err != nil { 228 l.Error("failed to save last time us", "error", err) 229 } 230 } 231 232 l.Info("found last time_us", "time_us", lastTimeUs) 233 return &lastTimeUs 234} 235 236func (j *JetstreamClient) saveIfKilled(ctx context.Context) context.Context { 237 ctxWithCancel, cancel := context.WithCancel(ctx) 238 239 sigChan := make(chan os.Signal, 1) 240 241 signal.Notify(sigChan, 242 syscall.SIGINT, 243 syscall.SIGTERM, 244 syscall.SIGQUIT, 245 syscall.SIGHUP, 246 syscall.SIGKILL, 247 syscall.SIGSTOP, 248 ) 249 250 go func() { 251 sig := <-sigChan 252 j.l.Info("Received signal, initiating graceful shutdown", "signal", sig) 253 254 if seen := j.lastSeenUs.Load(); seen != 0 { 255 if err := j.db.SaveLastTimeUs(seen); err != nil { 256 j.l.Error("Failed to save last time during shutdown", "error", err) 257 } 258 j.l.Info("Saved lastTimeUs before shutdown", "lastTimeUs", seen) 259 } 260 261 j.cancelMu.Lock() 262 if j.cancel != nil { 263 j.cancel() 264 } 265 j.cancelMu.Unlock() 266 267 cancel() 268 269 os.Exit(0) 270 }() 271 272 return ctxWithCancel 273}