This repository has no description
0

Configure Feed

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

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