···33import (
44 "database/sql"
55 "embed"
66+ "fmt"
77+ "html"
68 "html/template"
79 "log/slog"
810 "net/http"
911 "strconv"
1012 "time"
11131414+ "github.com/bluesky-social/indigo/atproto/syntax"
1215 "github.com/go-chi/chi/v5"
1316 "tangled.org/core/appview/pagination"
1417 "tangled.org/core/knotmirror/db"
···2124const repoPageSize = 20
22252326type AdminServer struct {
2424- db *sql.DB
2727+ db *sql.DB
2828+ resyncer *Resyncer
2929+ logger *slog.Logger
2530}
26312727-func NewAdminServer(database *sql.DB) *AdminServer {
2828- return &AdminServer{db: database}
3232+func NewAdminServer(l *slog.Logger, database *sql.DB, resyncer *Resyncer) *AdminServer {
3333+ return &AdminServer{
3434+ db: database,
3535+ resyncer: resyncer,
3636+ logger: l,
3737+ }
2938}
30393140func (s *AdminServer) Router() http.Handler {
3241 r := chi.NewRouter()
3342 r.Get("/repos", s.handleRepos())
3443 r.Get("/hosts", s.handleHosts())
4444+4545+ // not sure how to use these. should we vibe-code the admin page with React?
4646+ r.Post("/api/triggerRepoResync", s.handleRepoResyncTrigger())
4747+ r.Post("/api/cancelRepoResync", s.handleRepoResyncCancel())
4848+ r.Post("/api/testNotif", s.handleTestNotif)
3549 return r
3650}
3751···106120 }
107121 }
108122}
123123+124124+func (s *AdminServer) handleRepoResyncTrigger() http.HandlerFunc {
125125+ return func(w http.ResponseWriter, r *http.Request) {
126126+ var repoQuery = r.FormValue("repo")
127127+128128+ repo, err := syntax.ParseATURI(repoQuery)
129129+ if err != nil || repo.RecordKey() == "" {
130130+ writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery))
131131+ return
132132+ }
133133+134134+ if err := s.resyncer.TriggerResyncJob(r.Context(), repo); err != nil {
135135+ s.logger.Error("failed to trigger resync job", "err", err)
136136+ writeNotif(w, http.StatusInternalServerError, fmt.Sprintf("repo parameter invalid: %s", repoQuery))
137137+ return
138138+ }
139139+ writeNotif(w, http.StatusOK, "success")
140140+ }
141141+}
142142+143143+func (s *AdminServer) handleRepoResyncCancel() http.HandlerFunc {
144144+ return func(w http.ResponseWriter, r *http.Request) {
145145+ var repoQuery = r.FormValue("repo")
146146+147147+ repo, err := syntax.ParseATURI(repoQuery)
148148+ if err != nil || repo.RecordKey() == "" {
149149+ writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery))
150150+ return
151151+ }
152152+153153+ s.resyncer.CancelResyncJob(repo)
154154+ writeNotif(w, http.StatusOK, "success")
155155+ }
156156+}
157157+158158+func (s *AdminServer) handleTestNotif(w http.ResponseWriter, r *http.Request) {
159159+ writeNotif(w, http.StatusOK, "new notifi")
160160+}
161161+162162+func writeNotif(w http.ResponseWriter, status int, msg string) {
163163+ w.Header().Set("Content-Type", "text/html")
164164+ w.WriteHeader(status)
165165+166166+ class := "info"
167167+ switch {
168168+ case status >= 500:
169169+ class = "error"
170170+ case status >= 400:
171171+ class = "warn"
172172+ }
173173+174174+ fmt.Fprintf(w,
175175+ `<div class="notif %s" hx-swap-oob="beforeend:#notifications">%s</div>`,
176176+ class,
177177+ html.EscapeString(msg),
178178+ )
179179+}
+1-1
knotmirror/knotmirror.go
···5959 knotstream := knotstream.NewKnotStream(logger, db, cfg)
6060 crawler := NewCrawler(logger, db)
6161 resyncer := NewResyncer(logger, db, gitc, cfg)
6262- adminpage := NewAdminServer(db)
6262+ adminpage := NewAdminServer(logger, db, resyncer)
63636464 // maintain repository list with tap
6565 // NOTE: this can be removed once we introduce did-for-repo because then we can just listen to KnotStream for #identity events.
+70-4
knotmirror/resyncer.go
···25252626 claimJobMu sync.Mutex
27272828- repoFetchTimeout time.Duration
2828+ runningJobs map[syntax.ATURI]context.CancelFunc
2929+ runningJobsMu sync.Mutex
3030+3131+ repoFetchTimeout time.Duration
3232+ manualResyncTimeout time.Duration
29333034 parallelism int
3135}
···38423943 repoFetchTimeout: cfg.GitRepoFetchTimeout,
4044 parallelism: cfg.ResyncParallelism,
4545+4646+ manualResyncTimeout: 30 * time.Minute,
4147 }
4248}
4349···7379 }
7480}
75818282+func (r *Resyncer) registerRunning(repo syntax.ATURI, cancel context.CancelFunc) {
8383+ r.runningJobsMu.Lock()
8484+ defer r.runningJobsMu.Unlock()
8585+8686+ if _, exists := r.runningJobs[repo]; exists {
8787+ return
8888+ }
8989+ r.runningJobs[repo] = cancel
9090+}
9191+9292+func (r *Resyncer) unregisterRunning(repo syntax.ATURI) {
9393+ r.runningJobsMu.Lock()
9494+ defer r.runningJobsMu.Unlock()
9595+9696+ delete(r.runningJobs, repo)
9797+}
9898+9999+func (r *Resyncer) CancelResyncJob(repo syntax.ATURI) {
100100+ r.runningJobsMu.Lock()
101101+ defer r.runningJobsMu.Unlock()
102102+103103+ cancel, ok := r.runningJobs[repo]
104104+ if !ok {
105105+ return
106106+ }
107107+ delete(r.runningJobs, repo)
108108+ cancel()
109109+}
110110+111111+// TriggerResyncJob manually triggers the resync job
112112+func (r *Resyncer) TriggerResyncJob(ctx context.Context, repoAt syntax.ATURI) error {
113113+ repo, err := db.GetRepoByAtUri(ctx, r.db, repoAt)
114114+ if err != nil {
115115+ return fmt.Errorf("failed to get repo: %w", err)
116116+ }
117117+ if repo == nil {
118118+ return fmt.Errorf("repo not found: %s", repoAt)
119119+ }
120120+121121+ if repo.State == models.RepoStateResyncing {
122122+ return fmt.Errorf("repo already resyncing")
123123+ }
124124+125125+ repo.State = models.RepoStatePending
126126+ repo.RetryAfter = -1 // resyncer will prioritize this
127127+128128+ if err := db.UpsertRepo(ctx, r.db, repo); err != nil {
129129+ return fmt.Errorf("updating repo state to pending %w", err)
130130+ }
131131+ return nil
132132+}
133133+76134func (r *Resyncer) claimResyncJob(ctx context.Context) (syntax.ATURI, bool, error) {
77135 // use mutex to prevent duplicated jobs
78136 r.claimJobMu.Lock()
···86144 where at_uri = (
87145 select at_uri from repos
88146 where state in ($2, $3, $4)
8989- and (retry_after = 0 or retry_after < $5)
147147+ and (retry_after = -1 or retry_after = 0 or retry_after < $5)
90148 limit 1
91149 )
92150 returning at_uri
···112170 resyncsStarted.Inc()
113171 startTime := time.Now()
114172115115- success, err := r.doResync(ctx, repoAt)
173173+ jobCtx, cancel := context.WithCancel(ctx)
174174+ r.registerRunning(repoAt, cancel)
175175+ defer r.unregisterRunning(repoAt)
176176+177177+ success, err := r.doResync(jobCtx, repoAt)
116178 if !success {
117179 resyncsFailed.Inc()
118180 resyncDuration.Observe(time.Since(startTime).Seconds())
···140202 // TODO: check if Knot is on backoff list. If so, return (false, nil)
141203 // TODO: detect rate limit error (http.StatusTooManyRequests) to put Knot in backoff list
142204143143- fetchCtx, cancel := context.WithTimeout(ctx, r.repoFetchTimeout)
205205+ timeout := r.repoFetchTimeout
206206+ if repo.RetryAfter == -1 {
207207+ timeout = r.manualResyncTimeout
208208+ }
209209+ fetchCtx, cancel := context.WithTimeout(ctx, timeout)
144210 defer cancel()
145211146212 if err := r.gitc.Sync(fetchCtx, repo); err != nil {