internal/control/buildfollow.go

v1.36.0
gitbay/internal/control/buildfollow.go history · blame · raw

188 lines · 5218 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"sync"
  7	"time"
  8
  9	"gitbay.org/gitbay/internal/policy"
 10	"gitbay.org/gitbay/internal/protocol"
 11	"gitbay.org/gitbay/internal/store"
 12)
 13
 14// maxFollows is how many build log follows one account holds open at
 15// once. Signed-out web viewers are account 0 and share it.
 16const maxFollows = 8
 17
 18var (
 19	// followPoll bounds a wait with no wake. A write from another process
 20	// (gitbayd admin, or any session under gitbayd shell) wakes nobody;
 21	// this is how its bytes still arrive.
 22	followPoll = 2 * time.Second
 23	// followSettle is how long a follow keeps reading after the build has
 24	// an outcome: a cancel appends its line after the status changes, and
 25	// a cancelled runner's stream runs on until its next check.
 26	followSettle = time.Second
 27	// followQueued bounds how long a follow waits on a build that stays
 28	// pending: nothing reaps a queued build (ReapStaleBuilds only reaps
 29	// running builds), and a running one is already bounded by the
 30	// reaper's deadline, so a follow needs its own limit for the queued
 31	// case or it never ends.
 32	followQueued = 10 * time.Minute
 33	// followCoalesce is the pause between a wake and the read it causes.
 34	// A runner appends a chunk per read of its output, often a line, and
 35	// each read loads the whole stored log; one read after a short pause
 36	// takes a burst of appends together.
 37	followCoalesce = 200 * time.Millisecond
 38)
 39
 40var (
 41	followMu sync.Mutex
 42	follows  = map[int64]int{}
 43)
 44
 45func takeFollow(uid int64) bool {
 46	followMu.Lock()
 47	defer followMu.Unlock()
 48	if follows[uid] >= maxFollows {
 49		return false
 50	}
 51	follows[uid]++
 52	return true
 53}
 54
 55func dropFollow(uid int64) {
 56	followMu.Lock()
 57	defer followMu.Unlock()
 58	if follows[uid]--; follows[uid] <= 0 {
 59		delete(follows, uid)
 60	}
 61}
 62
 63// mayStillRead reports whether the follower can still read the build's
 64// repository. It looks the repository up by id, so a rename mid-follow
 65// does not end the follow, and reloads the account, so disabling it
 66// does.
 67func mayStillRead(c *Ctx, repoID int64) (bool, error) {
 68	repo, err := c.Store.RepoByID(repoID)
 69	if errors.Is(err, store.ErrNotFound) {
 70		return false, nil
 71	}
 72	if err != nil {
 73		return false, err
 74	}
 75	u := c.User
 76	if u.ID != 0 {
 77		u, err = c.Store.UserByID(u.ID)
 78		if errors.Is(err, store.ErrNotFound) {
 79			return false, nil
 80		}
 81		if err != nil {
 82			return false, err
 83		}
 84		if u.Disabled {
 85			return false, nil
 86		}
 87	}
 88	grant, err := c.Store.AccessRole(repo.ID, u.ID)
 89	if err != nil {
 90		return false, err
 91	}
 92	return policy.CanRead(u, repo, grant), nil
 93}
 94
 95// ended is what a follow returns when its Done closes. A restart says
 96// so, whatever the surface, so the reader knows to follow again; a
 97// reader who left hears nothing.
 98func ended(c *Ctx) int {
 99	select {
100	case <-c.Stopping:
101		fmt.Fprintln(c.Stderr, "gitbay is restarting; follow the build again in a moment")
102	default:
103	}
104	return protocol.ExitFailure
105}
106
107// followBuildLog writes the build's log as it grows and returns once the
108// build has an outcome and its last bytes are written. The outcome goes
109// to stderr, so stdout is the log byte for byte. Read access is checked
110// again every followPoll: a repository made private, or a grant revoked,
111// ends the follow with the answer a new request would get.
112func followBuildLog(c *Ctx, repo store.Repo, b store.Build) int {
113	if !takeFollow(c.User.ID) {
114		return c.fail(protocol.ExitDenied, "%d follows are already open for this account; close one and retry", maxFollows)
115	}
116	defer dropFollow(c.User.ID)
117
118	var off int64
119	var settleBy time.Time
120	var queuedSince time.Time
121	checked := time.Now()
122	for {
123		if time.Since(checked) >= followPoll {
124			ok, err := mayStillRead(c, b.RepoID)
125			if err != nil {
126				return c.fail(protocol.ExitFailure, "%v", err)
127			}
128			if !ok {
129				return c.fail(protocol.ExitNotFound, "repository %s not found", repo.Path())
130			}
131			checked = time.Now()
132		}
133		wake := c.Store.BuildLogWait(b.ID)
134		status, chunk, err := c.Store.BuildLogFrom(b.ID, off)
135		if err != nil {
136			return c.fail(protocol.ExitFailure, "%v", err)
137		}
138		if len(chunk) > 0 {
139			if _, err := c.Stdout.Write(chunk); err != nil {
140				return protocol.ExitFailure
141			}
142			off += int64(len(chunk))
143		}
144		if status == "pending" {
145			if queuedSince.IsZero() {
146				queuedSince = time.Now()
147			}
148		} else {
149			queuedSince = time.Time{}
150		}
151		wait := followPoll
152		if status == "pending" {
153			left := queuedSince.Add(followQueued).Sub(time.Now())
154			if left <= 0 {
155				fmt.Fprintf(c.Stderr, "build %d is still queued; nothing claimed it in %v. Follow again once a runner has.\n", b.Number, followQueued)
156				return protocol.ExitFailure
157			}
158			wait = min(wait, left)
159		}
160		if status != "pending" && status != "running" {
161			if settleBy.IsZero() {
162				settleBy = time.Now().Add(followSettle)
163			}
164			left := time.Until(settleBy)
165			if left <= 0 && len(chunk) == 0 {
166				fmt.Fprintf(c.Stderr, "build %d %s\n", b.Number, status)
167				return protocol.ExitOK
168			}
169			wait = min(wait, max(left, 0))
170		}
171		t := time.NewTimer(wait)
172		select {
173		case <-wake:
174			t.Reset(followCoalesce)
175			select {
176			case <-t.C:
177			case <-c.Done:
178				t.Stop()
179				return ended(c)
180			}
181		case <-t.C:
182		case <-c.Done:
183			t.Stop()
184			return ended(c)
185		}
186		t.Stop()
187	}
188}