detached b05e4c1

Merge 70b06a9f29d971fa41ac1d9c7c16d9bcb232d082 into 067ccd6a6bf0f9be62c0536cd1d13c79957cb708

commit
b05e4c1786277d82ed814298297b271aef4402af
author
ermite · · 2026-10-20 13:09 UTC
merge of
067ccd6 + 70b06a9 · diff against the first parent
signature
unsigned
7 files · +231 -25
M Dockerfile +4 -3
@@ -26,9 +26,10 @@ RUN --mount=type=cache,target=/go/pkg/mod \
2626 -o /out/ermite ./cmd/ermite
2727
2828 FROM alpine:3.24
29-# git-daemon provides git-http-backend on alpine. tini runs as PID 1: it forwards signals
30-# to ermite and reaps the git processes that detach from pushes (auto maintenance).
31-RUN apk add --no-cache git git-daemon ca-certificates tini \
29+# git-daemon provides git-http-backend on alpine. openssh-client lets push mirrors reach
30+# their SSH remotes. tini runs as PID 1: it forwards signals to ermite and reaps the git
31+# processes that detach from pushes (auto maintenance).
32+RUN apk add --no-cache git git-daemon openssh-client ca-certificates tini \
3233 && adduser -D -u 10001 -h /home/ermite ermite \
3334 && mkdir /data \
3435 && chown ermite:ermite /data
@@ -26,9 +26,10 @@ RUN --mount=type=cache,target=/go/pkg/mod \
26 -o /out/ermite ./cmd/ermite26 -o /out/ermite ./cmd/ermite
27 27
28 FROM alpine:3.2428 FROM alpine:3.24
29-# git-daemon provides git-http-backend on alpine. tini runs as PID 1: it forwards signals29+# git-daemon provides git-http-backend on alpine. openssh-client lets push mirrors reach
30-# to ermite and reaps the git processes that detach from pushes (auto maintenance).30+# their SSH remotes. tini runs as PID 1: it forwards signals to ermite and reaps the git
31-RUN apk add --no-cache git git-daemon ca-certificates tini \31+# processes that detach from pushes (auto maintenance).
32+RUN apk add --no-cache git git-daemon openssh-client ca-certificates tini \
32 && adduser -D -u 10001 -h /home/ermite ermite \33 && adduser -D -u 10001 -h /home/ermite ermite \
33 && mkdir /data \34 && mkdir /data \
34 && chown ermite:ermite /data35 && chown ermite:ermite /data
M docs/mirror.md +1 -0
@@ -44,6 +44,7 @@ URLs are `git@host:path` or `ssh://[user@]host[:port]/path`, with no password. T
4444
4545 - On the cron schedule. A push to ermite only marks the repository as changed; the next due cron tick syncs it. A tick with nothing new does nothing.
4646 - `ermite mirror sync <repo>` or `ermite mirror sync --all` (owner only) syncs now; a repository needs admin rights. `--all` skips repositories without a mirror, continues past failures, prints one row per repository and exits with status 1 if any failed.
47+- A manual sync waits at most about 8 s (the edge proxy cuts responses at 10 s, the CLI at 30 s, while an SSH push to an unreachable host can take 30 s or more). `--all` queues every repository at once and the server pushes a few at a time (2 in parallel), so the wait stays one bound, not one per repository. A sync still running or still waiting for its turn then is shown as `QUEUED true`; the CLI says so on stderr, the sync continues on the server, and `ermite mirror status` shows its outcome.
4748 - `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes.
4849 - Overlapping syncs of a repository coalesce; only one push runs at a time per repository.
4950
@@ -44,6 +44,7 @@ URLs are `git@host:path` or `ssh://[user@]host[:port]/path`, with no password. T
44 44
45 - On the cron schedule. A push to ermite only marks the repository as changed; the next due cron tick syncs it. A tick with nothing new does nothing.45 - On the cron schedule. A push to ermite only marks the repository as changed; the next due cron tick syncs it. A tick with nothing new does nothing.
46 - `ermite mirror sync <repo>` or `ermite mirror sync --all` (owner only) syncs now; a repository needs admin rights. `--all` skips repositories without a mirror, continues past failures, prints one row per repository and exits with status 1 if any failed.46 - `ermite mirror sync <repo>` or `ermite mirror sync --all` (owner only) syncs now; a repository needs admin rights. `--all` skips repositories without a mirror, continues past failures, prints one row per repository and exits with status 1 if any failed.
47+- A manual sync waits at most about 8 s (the edge proxy cuts responses at 10 s, the CLI at 30 s, while an SSH push to an unreachable host can take 30 s or more). `--all` queues every repository at once and the server pushes a few at a time (2 in parallel), so the wait stays one bound, not one per repository. A sync still running or still waiting for its turn then is shown as `QUEUED true`; the CLI says so on stderr, the sync continues on the server, and `ermite mirror status` shows its outcome.
47 - `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes.48 - `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes.
48 - Overlapping syncs of a repository coalesce; only one push runs at a time per repository.49 - Overlapping syncs of a repository coalesce; only one push runs at a time per repository.
49 50
M internal/app/cli_mirror.go +9 -2
@@ -275,9 +275,10 @@ func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
275275 return cmd
276276 }
277277
278+// newMirrorSyncCmd describes the server's wait bound (usecase.DefaultMirrorSyncWait, 8 s).
278279 func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
279280 var all, force bool
280- cmd := &cobra.Command{Use: "sync <repo>|--all", Short: "Sync mirrors now", Long: "Sync one repository, or every repository with --all (owner only).\n--force never force-pushes: it only skips the no-op check and throttle and lifts a pause.", Args: func(_ *cobra.Command, args []string) error {
281+ cmd := &cobra.Command{Use: "sync <repo>|--all", Short: "Sync mirrors now", Long: "Sync one repository, or every repository with --all (owner only).\nThe server pushes a few repositories at a time. The command waits about 8 s; a sync still running or waiting\nthen is reported as queued, keeps going on the server, and its outcome is shown by `ermite mirror status`.\n--force never force-pushes: it only skips the no-op check and throttle and lifts a pause.", Args: func(_ *cobra.Command, args []string) error {
281282 if (all && len(args) == 0) || (!all && len(args) == 1) {
282283 return nil
283284 }
@@ -298,9 +299,12 @@ func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
298299 if err != nil {
299300 return c.Describe(err)
300301 }
301- failed := 0
302+ failed, pending := 0, 0
302303 rows := make([][]string, 0, len(res.Msg.GetResults()))
303304 for _, r := range res.Msg.GetResults() {
305+ if r.GetQueued() {
306+ pending++
307+ }
304308 reason := r.GetStatus().GetReason()
305309 if r.GetError() != "" {
306310 failed++
@@ -311,6 +315,9 @@ func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
311315 if err := table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows); err != nil {
312316 return err
313317 }
318+ if pending > 0 {
319+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%d of %d mirror syncs still running: they continue in the background; check `ermite mirror status`\n", pending, len(rows))
320+ }
314321 if failed > 0 {
315322 return fmt.Errorf("%d of %d mirror syncs failed", failed, len(rows))
316323 }
@@ -275,9 +275,10 @@ func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
275 return cmd275 return cmd
276 }276 }
277 277
278+// newMirrorSyncCmd describes the server's wait bound (usecase.DefaultMirrorSyncWait, 8 s).
278 func newMirrorSyncCmd(env *cliEnv) *cobra.Command {279 func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
279 var all, force bool280 var all, force bool
280- cmd := &cobra.Command{Use: "sync <repo>|--all", Short: "Sync mirrors now", Long: "Sync one repository, or every repository with --all (owner only).\n--force never force-pushes: it only skips the no-op check and throttle and lifts a pause.", Args: func(_ *cobra.Command, args []string) error {281+ cmd := &cobra.Command{Use: "sync <repo>|--all", Short: "Sync mirrors now", Long: "Sync one repository, or every repository with --all (owner only).\nThe server pushes a few repositories at a time. The command waits about 8 s; a sync still running or waiting\nthen is reported as queued, keeps going on the server, and its outcome is shown by `ermite mirror status`.\n--force never force-pushes: it only skips the no-op check and throttle and lifts a pause.", Args: func(_ *cobra.Command, args []string) error {
281 if (all && len(args) == 0) || (!all && len(args) == 1) {282 if (all && len(args) == 0) || (!all && len(args) == 1) {
282 return nil283 return nil
283 }284 }
@@ -298,9 +299,12 @@ func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
298 if err != nil {299 if err != nil {
299 return c.Describe(err)300 return c.Describe(err)
300 }301 }
301- failed := 0302+ failed, pending := 0, 0
302 rows := make([][]string, 0, len(res.Msg.GetResults()))303 rows := make([][]string, 0, len(res.Msg.GetResults()))
303 for _, r := range res.Msg.GetResults() {304 for _, r := range res.Msg.GetResults() {
305+ if r.GetQueued() {
306+ pending++
307+ }
304 reason := r.GetStatus().GetReason()308 reason := r.GetStatus().GetReason()
305 if r.GetError() != "" {309 if r.GetError() != "" {
306 failed++310 failed++
@@ -311,6 +315,9 @@ func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
311 if err := table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows); err != nil {315 if err := table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows); err != nil {
312 return err316 return err
313 }317 }
318+ if pending > 0 {
319+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%d of %d mirror syncs still running: they continue in the background; check `ermite mirror status`\n", pending, len(rows))
320+ }
314 if failed > 0 {321 if failed > 0 {
315 return fmt.Errorf("%d of %d mirror syncs failed", failed, len(rows))322 return fmt.Errorf("%d of %d mirror syncs failed", failed, len(rows))
316 }323 }
M internal/app/cli_mirror_test.go +50 -0
@@ -3,11 +3,18 @@
33 package app
44
55 import (
6+ "context"
67 "strings"
78 "testing"
9+ "time"
810
911 "github.com/stretchr/testify/assert"
1012 "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/config"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/usecase"
1118 )
1219
1320 func TestMirrorCLIArguments(t *testing.T) {
@@ -65,3 +72,46 @@ func TestMirrorCLIConfiguration(t *testing.T) {
6572 assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)
6673 assert.Equal(t, []string{"authorized_key", "created_at", "fingerprint"}, keys(t, run("key", "show", "--json").ok(t).stdout))
6774 }
75+
76+func TestMirrorCLISyncHelpDocumentsBackgroundSync(t *testing.T) {
77+ r := runCLI(t, nil, "", "mirror", "sync", "--help").ok(t)
78+ assert.Contains(t, r.stdout, "queued")
79+ assert.Contains(t, r.stdout, "ermite mirror status")
80+}
81+
82+type blockingPusher struct{ entered, release chan struct{} }
83+
84+func (p blockingPusher) Push(ctx context.Context, _ ports.MirrorPush) (ports.MirrorPushResult, error) {
85+ p.entered <- struct{}{}
86+ select {
87+ case <-p.release:
88+ case <-ctx.Done():
89+ }
90+ return ports.MirrorPushResult{}, nil
91+}
92+
93+// A push that outlasts the server's wait bound answers "queued"; the CLI says it continues.
94+func TestMirrorCLISyncReportsBackgroundSync(t *testing.T) {
95+ p := blockingPusher{entered: make(chan struct{}, 4), release: make(chan struct{})}
96+ h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
97+ o.mirrors = func(c *usecase.MirrorsConfig) {
98+ c.ValidateURL = func(string) error { return nil }
99+ c.Pusher = p
100+ c.SyncWait = 50 * time.Millisecond
101+ }
102+ })
103+ h.createRepo("proj", domain.VisibilityPrivate)
104+ c := &cliHarness{harness: h, url: h.srv.URL}
105+ wt := h.workTree("main", "README.md")
106+ h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
107+ c.cli(t, adminToken, "mirror", "add", "proj", "file:///nowhere", "--cron", "*/5 * * * *").ok(t)
108+ for _, args := range [][]string{{"sync", "proj", "--force"}, {"sync", "--all", "--force"}} {
109+ r := c.cli(t, adminToken, append([]string{"mirror"}, args...)...).ok(t)
110+ assert.Contains(t, r.stdout, "proj")
111+ assert.Contains(t, r.stdout, "true", "QUEUED column")
112+ assert.Contains(t, r.stderr, "continue in the background")
113+ assert.Contains(t, r.stderr, "ermite mirror status")
114+ }
115+ close(p.release)
116+ h.forge.Mirrors.Wait(h.ctx)
117+}
@@ -3,11 +3,18 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "context"
6 "strings"7 "strings"
7 "testing"8 "testing"
9+ "time"
8 10
9 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/config"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/usecase"
11 )18 )
12 19
13 func TestMirrorCLIArguments(t *testing.T) {20 func TestMirrorCLIArguments(t *testing.T) {
@@ -65,3 +72,46 @@ func TestMirrorCLIConfiguration(t *testing.T) {
65 assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)72 assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)
66 assert.Equal(t, []string{"authorized_key", "created_at", "fingerprint"}, keys(t, run("key", "show", "--json").ok(t).stdout))73 assert.Equal(t, []string{"authorized_key", "created_at", "fingerprint"}, keys(t, run("key", "show", "--json").ok(t).stdout))
67 }74 }
75+
76+func TestMirrorCLISyncHelpDocumentsBackgroundSync(t *testing.T) {
77+ r := runCLI(t, nil, "", "mirror", "sync", "--help").ok(t)
78+ assert.Contains(t, r.stdout, "queued")
79+ assert.Contains(t, r.stdout, "ermite mirror status")
80+}
81+
82+type blockingPusher struct{ entered, release chan struct{} }
83+
84+func (p blockingPusher) Push(ctx context.Context, _ ports.MirrorPush) (ports.MirrorPushResult, error) {
85+ p.entered <- struct{}{}
86+ select {
87+ case <-p.release:
88+ case <-ctx.Done():
89+ }
90+ return ports.MirrorPushResult{}, nil
91+}
92+
93+// A push that outlasts the server's wait bound answers "queued"; the CLI says it continues.
94+func TestMirrorCLISyncReportsBackgroundSync(t *testing.T) {
95+ p := blockingPusher{entered: make(chan struct{}, 4), release: make(chan struct{})}
96+ h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
97+ o.mirrors = func(c *usecase.MirrorsConfig) {
98+ c.ValidateURL = func(string) error { return nil }
99+ c.Pusher = p
100+ c.SyncWait = 50 * time.Millisecond
101+ }
102+ })
103+ h.createRepo("proj", domain.VisibilityPrivate)
104+ c := &cliHarness{harness: h, url: h.srv.URL}
105+ wt := h.workTree("main", "README.md")
106+ h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
107+ c.cli(t, adminToken, "mirror", "add", "proj", "file:///nowhere", "--cron", "*/5 * * * *").ok(t)
108+ for _, args := range [][]string{{"sync", "proj", "--force"}, {"sync", "--all", "--force"}} {
109+ r := c.cli(t, adminToken, append([]string{"mirror"}, args...)...).ok(t)
110+ assert.Contains(t, r.stdout, "proj")
111+ assert.Contains(t, r.stdout, "true", "QUEUED column")
112+ assert.Contains(t, r.stderr, "continue in the background")
113+ assert.Contains(t, r.stderr, "ermite mirror status")
114+ }
115+ close(p.release)
116+ h.forge.Mirrors.Wait(h.ctx)
117+}
M internal/usecase/mirrors.go +6 -0
@@ -29,6 +29,9 @@ type MirrorsConfig struct {
2929 ScheduleTick time.Duration
3030 Workers int
3131 QueueSize int // jobs waiting for a worker; default 64
32+ // SyncWait bounds how long a manual sync (Sync, SyncAll) holds its caller; past it the
33+ // result is Queued and the push continues. Default DefaultMirrorSyncWait.
34+ SyncWait time.Duration
3235 // ValidateURL checks a resolved mirror URL; default domain.ValidateMirrorURL. Tests of
3336 // local transports inject a permissive check.
3437 ValidateURL func(string) error
@@ -76,6 +79,9 @@ func NewMirrors(cfg MirrorsConfig) *Mirrors {
7679 if cfg.QueueSize <= 0 {
7780 cfg.QueueSize = mirrorQueueSize
7881 }
82+ if cfg.SyncWait <= 0 {
83+ cfg.SyncWait = DefaultMirrorSyncWait
84+ }
7985 if cfg.ValidateURL == nil {
8086 cfg.ValidateURL = domain.ValidateMirrorURL
8187 }
@@ -29,6 +29,9 @@ type MirrorsConfig struct {
29 ScheduleTick time.Duration29 ScheduleTick time.Duration
30 Workers int30 Workers int
31 QueueSize int // jobs waiting for a worker; default 6431 QueueSize int // jobs waiting for a worker; default 64
32+ // SyncWait bounds how long a manual sync (Sync, SyncAll) holds its caller; past it the
33+ // result is Queued and the push continues. Default DefaultMirrorSyncWait.
34+ SyncWait time.Duration
32 // ValidateURL checks a resolved mirror URL; default domain.ValidateMirrorURL. Tests of35 // ValidateURL checks a resolved mirror URL; default domain.ValidateMirrorURL. Tests of
33 // local transports inject a permissive check.36 // local transports inject a permissive check.
34 ValidateURL func(string) error37 ValidateURL func(string) error
@@ -76,6 +79,9 @@ func NewMirrors(cfg MirrorsConfig) *Mirrors {
76 if cfg.QueueSize <= 0 {79 if cfg.QueueSize <= 0 {
77 cfg.QueueSize = mirrorQueueSize80 cfg.QueueSize = mirrorQueueSize
78 }81 }
82+ if cfg.SyncWait <= 0 {
83+ cfg.SyncWait = DefaultMirrorSyncWait
84+ }
79 if cfg.ValidateURL == nil {85 if cfg.ValidateURL == nil {
80 cfg.ValidateURL = domain.ValidateMirrorURL86 cfg.ValidateURL = domain.ValidateMirrorURL
81 }87 }
M internal/usecase/mirrors_sync.go +71 -20
@@ -36,7 +36,13 @@ var (
3636 _ ports.MirrorWarner = (*Mirrors)(nil)
3737 )
3838
39-// Sync waits for its own run; overlapping requests coalesce into one sticky rerun.
39+// DefaultMirrorSyncWait is how long a manual sync holds its caller before answering "queued":
40+// under the 10 s the edge proxy allows a response, and well under the client timeout.
41+const DefaultMirrorSyncWait = 8 * time.Second
42+
43+// Sync waits for its own run; overlapping requests coalesce into one sticky rerun. When the
44+// push outlasts the wait bound the result is Queued: the sync goes on in the background and
45+// its outcome is in the mirror status.
4046 func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) {
4147 if err := m.admin(ctx, p, repo); err != nil {
4248 return ports.MirrorSyncResult{}, err
@@ -47,22 +53,46 @@ func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.Repo
4753 return ports.MirrorSyncResult{}, err
4854 }
4955 if queued {
50- return ports.MirrorSyncResult{Repo: repo, Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil
56+ return pendingSync(repo), nil
5157 }
58+ wctx, cancel := context.WithTimeout(ctx, m.cfg.SyncWait)
59+ defer cancel()
60+ return m.await(ctx, wctx, repo, f)
61+}
62+
63+func pendingSync(repo domain.RepoName) ports.MirrorSyncResult {
64+ return ports.MirrorSyncResult{Repo: repo, Status: domain.MirrorStatus{Repo: repo}, Queued: true}
65+}
66+
67+// await waits for the first run of f. wctx carries the wait bound and ctx the caller's own
68+// cancellation: the bound expiring alone yields a Queued result, not an error.
69+func (m *Mirrors) await(ctx, wctx context.Context, repo domain.RepoName, f *mirrorFlight) (ports.MirrorSyncResult, error) {
5270 select {
5371 case <-m.ctx.Done():
5472 return ports.MirrorSyncResult{}, m.ctx.Err()
55- case <-ctx.Done():
56- return ports.MirrorSyncResult{}, ctx.Err()
73+ case <-wctx.Done():
74+ if err := ctx.Err(); err != nil {
75+ return ports.MirrorSyncResult{}, err
76+ }
77+ select { // a run that finished as the bound expired wins
78+ case <-f.done:
79+ default:
80+ return pendingSync(repo), nil
81+ }
5782 case <-f.done:
58- f.result.Repo = repo
59- return f.result, f.err
6083 }
84+ // run writes result and err before close(f.done): the close orders them before this read.
85+ res, err := f.result, f.err
86+ res.Repo = repo
87+ return res, err
6188 }
6289
63-// SyncAll syncs every repository whose mirror is enabled, as an owner. A failing repository
64-// does not stop the others: its outcome carries the error. The returned error is only for
65-// what prevents the whole request (authorization, listing, cancellation).
90+// SyncAll syncs every repository whose mirror is enabled, as an owner. Every repository is
91+// enqueued first, then all outcomes are awaited together under one wait bound, so the total
92+// time is about one push, not one per repository. A failing repository does not stop the
93+// others: its outcome carries the error; one still running at the bound is Queued. The
94+// returned error is only for what prevents the whole request (authorization, listing,
95+// cancellation). Results are in listing order.
6696 func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) {
6797 if err := mirrorOwner(p); err != nil {
6898 return nil, err
@@ -71,28 +101,49 @@ func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) (
71101 if err != nil {
72102 return nil, err
73103 }
74- out := make([]ports.MirrorSyncResult, 0, len(repos))
104+ m.Start()
105+ type entry struct {
106+ res ports.MirrorSyncResult
107+ f *mirrorFlight // nil once the outcome is known
108+ }
109+ entries := make([]entry, 0, len(repos))
75110 for _, r := range repos {
76111 if err := ctx.Err(); err != nil {
77- return out, err
112+ return nil, err
78113 }
79114 t, err := m.target(ctx, r.Name)
80115 if err == nil && !t.Enabled {
81116 continue
82117 }
83- var res ports.MirrorSyncResult
118+ e := entry{res: ports.MirrorSyncResult{Repo: r.Name, Err: err}}
84119 if err == nil {
85- res, err = m.Sync(ctx, p, r.Name, force)
120+ f, queued, qerr := m.enqueue(r.Name, mirrorReq{force: force, manual: true})
121+ switch {
122+ case qerr != nil:
123+ e.res.Err = qerr
124+ case queued:
125+ e.res = pendingSync(r.Name)
126+ default:
127+ e.f = f
128+ }
86129 }
87- if ctxErr := ctx.Err(); ctxErr != nil {
88- return out, ctxErr
130+ entries = append(entries, e)
131+ }
132+ wctx, cancel := context.WithTimeout(ctx, m.cfg.SyncWait)
133+ defer cancel()
134+ out := make([]ports.MirrorSyncResult, 0, len(entries))
135+ for _, e := range entries {
136+ if e.f != nil {
137+ res, err := m.await(ctx, wctx, e.res.Repo, e.f)
138+ if cerr := ctx.Err(); cerr != nil {
139+ return nil, cerr
140+ }
141+ e.res, e.res.Err = res, err
89142 }
90- res.Repo = r.Name
91- if res.Status.Repo == (domain.RepoName{}) {
92- res.Status.Repo = r.Name
143+ if e.res.Status.Repo == (domain.RepoName{}) {
144+ e.res.Status.Repo = e.res.Repo
93145 }
94- res.Err = err
95- out = append(out, res)
146+ out = append(out, e.res)
96147 }
97148 return out, nil
98149 }
@@ -36,7 +36,13 @@ var (
36 _ ports.MirrorWarner = (*Mirrors)(nil)36 _ ports.MirrorWarner = (*Mirrors)(nil)
37 )37 )
38 38
39-// Sync waits for its own run; overlapping requests coalesce into one sticky rerun.39+// DefaultMirrorSyncWait is how long a manual sync holds its caller before answering "queued":
40+// under the 10 s the edge proxy allows a response, and well under the client timeout.
41+const DefaultMirrorSyncWait = 8 * time.Second
42+
43+// Sync waits for its own run; overlapping requests coalesce into one sticky rerun. When the
44+// push outlasts the wait bound the result is Queued: the sync goes on in the background and
45+// its outcome is in the mirror status.
40 func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) {46 func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) {
41 if err := m.admin(ctx, p, repo); err != nil {47 if err := m.admin(ctx, p, repo); err != nil {
42 return ports.MirrorSyncResult{}, err48 return ports.MirrorSyncResult{}, err
@@ -47,22 +53,46 @@ func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.Repo
47 return ports.MirrorSyncResult{}, err53 return ports.MirrorSyncResult{}, err
48 }54 }
49 if queued {55 if queued {
50- return ports.MirrorSyncResult{Repo: repo, Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil56+ return pendingSync(repo), nil
51 }57 }
58+ wctx, cancel := context.WithTimeout(ctx, m.cfg.SyncWait)
59+ defer cancel()
60+ return m.await(ctx, wctx, repo, f)
61+}
62+
63+func pendingSync(repo domain.RepoName) ports.MirrorSyncResult {
64+ return ports.MirrorSyncResult{Repo: repo, Status: domain.MirrorStatus{Repo: repo}, Queued: true}
65+}
66+
67+// await waits for the first run of f. wctx carries the wait bound and ctx the caller's own
68+// cancellation: the bound expiring alone yields a Queued result, not an error.
69+func (m *Mirrors) await(ctx, wctx context.Context, repo domain.RepoName, f *mirrorFlight) (ports.MirrorSyncResult, error) {
52 select {70 select {
53 case <-m.ctx.Done():71 case <-m.ctx.Done():
54 return ports.MirrorSyncResult{}, m.ctx.Err()72 return ports.MirrorSyncResult{}, m.ctx.Err()
55- case <-ctx.Done():73+ case <-wctx.Done():
56- return ports.MirrorSyncResult{}, ctx.Err()74+ if err := ctx.Err(); err != nil {
75+ return ports.MirrorSyncResult{}, err
76+ }
77+ select { // a run that finished as the bound expired wins
78+ case <-f.done:
79+ default:
80+ return pendingSync(repo), nil
81+ }
57 case <-f.done:82 case <-f.done:
58- f.result.Repo = repo
59- return f.result, f.err
60 }83 }
84+ // run writes result and err before close(f.done): the close orders them before this read.
85+ res, err := f.result, f.err
86+ res.Repo = repo
87+ return res, err
61 }88 }
62 89
63-// SyncAll syncs every repository whose mirror is enabled, as an owner. A failing repository90+// SyncAll syncs every repository whose mirror is enabled, as an owner. Every repository is
64-// does not stop the others: its outcome carries the error. The returned error is only for91+// enqueued first, then all outcomes are awaited together under one wait bound, so the total
65-// what prevents the whole request (authorization, listing, cancellation).92+// time is about one push, not one per repository. A failing repository does not stop the
93+// others: its outcome carries the error; one still running at the bound is Queued. The
94+// returned error is only for what prevents the whole request (authorization, listing,
95+// cancellation). Results are in listing order.
66 func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) {96 func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) {
67 if err := mirrorOwner(p); err != nil {97 if err := mirrorOwner(p); err != nil {
68 return nil, err98 return nil, err
@@ -71,28 +101,49 @@ func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) (
71 if err != nil {101 if err != nil {
72 return nil, err102 return nil, err
73 }103 }
74- out := make([]ports.MirrorSyncResult, 0, len(repos))104+ m.Start()
105+ type entry struct {
106+ res ports.MirrorSyncResult
107+ f *mirrorFlight // nil once the outcome is known
108+ }
109+ entries := make([]entry, 0, len(repos))
75 for _, r := range repos {110 for _, r := range repos {
76 if err := ctx.Err(); err != nil {111 if err := ctx.Err(); err != nil {
77- return out, err112+ return nil, err
78 }113 }
79 t, err := m.target(ctx, r.Name)114 t, err := m.target(ctx, r.Name)
80 if err == nil && !t.Enabled {115 if err == nil && !t.Enabled {
81 continue116 continue
82 }117 }
83- var res ports.MirrorSyncResult118+ e := entry{res: ports.MirrorSyncResult{Repo: r.Name, Err: err}}
84 if err == nil {119 if err == nil {
85- res, err = m.Sync(ctx, p, r.Name, force)120+ f, queued, qerr := m.enqueue(r.Name, mirrorReq{force: force, manual: true})
121+ switch {
122+ case qerr != nil:
123+ e.res.Err = qerr
124+ case queued:
125+ e.res = pendingSync(r.Name)
126+ default:
127+ e.f = f
128+ }
86 }129 }
87- if ctxErr := ctx.Err(); ctxErr != nil {130+ entries = append(entries, e)
88- return out, ctxErr131+ }
132+ wctx, cancel := context.WithTimeout(ctx, m.cfg.SyncWait)
133+ defer cancel()
134+ out := make([]ports.MirrorSyncResult, 0, len(entries))
135+ for _, e := range entries {
136+ if e.f != nil {
137+ res, err := m.await(ctx, wctx, e.res.Repo, e.f)
138+ if cerr := ctx.Err(); cerr != nil {
139+ return nil, cerr
140+ }
141+ e.res, e.res.Err = res, err
89 }142 }
90- res.Repo = r.Name143+ if e.res.Status.Repo == (domain.RepoName{}) {
91- if res.Status.Repo == (domain.RepoName{}) {144+ e.res.Status.Repo = e.res.Repo
92- res.Status.Repo = r.Name
93 }145 }
94- res.Err = err146+ out = append(out, e.res)
95- out = append(out, res)
96 }147 }
97 return out, nil148 return out, nil
98 }149 }
M internal/usecase/mirrors_sync_test.go +90 -0
@@ -490,3 +490,93 @@ func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) {
490490 _, err = f.m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead))
491491 require.ErrorIs(t, err, domain.ErrForbidden)
492492 }
493+
494+// gatedPush makes every push announce itself on entered and block until release is closed.
495+func (f *mirrorFixture) gatedPush(release <-chan struct{}) <-chan struct{} {
496+ entered := make(chan struct{}, 16)
497+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.MirrorPush) (ports.MirrorPushResult, error) {
498+ entered <- struct{}{}
499+ select {
500+ case <-release:
501+ case <-ctx.Done():
502+ }
503+ return ports.MirrorPushResult{}, nil
504+ }).Maybe()
505+ return entered
506+}
507+
508+func TestMirrorsSyncAllRunsRepositoriesConcurrently(t *testing.T) {
509+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers = 3; c.SyncWait = time.Minute })
510+ f.addRepo(name(t, "b"))
511+ f.addRepo(name(t, "c"))
512+ release := make(chan struct{})
513+ entered := f.gatedPush(release)
514+ type outcome struct {
515+ got []ports.MirrorSyncResult
516+ err error
517+ }
518+ done := make(chan outcome, 1)
519+ go func() {
520+ got, err := f.m.SyncAll(context.Background(), domain.Root(), true)
521+ done <- outcome{got, err}
522+ }()
523+ // All three pushes are in flight together: a sequential SyncAll would never get past the first.
524+ for i := 0; i < 3; i++ {
525+ select {
526+ case <-entered:
527+ case <-time.After(10 * time.Second):
528+ t.Fatalf("only %d pushes started: repositories are not synced concurrently", i)
529+ }
530+ }
531+ close(release)
532+ o := <-done
533+ require.NoError(t, o.err)
534+ require.Len(t, o.got, 3)
535+ for i, want := range []string{"tools", "b", "c"} {
536+ r := o.got[i]
537+ require.Equal(t, want, r.Repo.String(), "results keep the listing order")
538+ require.NoError(t, r.Err)
539+ require.False(t, r.Queued)
540+ require.Equal(t, domain.MirrorOK, r.Status.State)
541+ }
542+}
543+
544+func TestMirrorsSyncReturnsQueuedWhenPushOutlastsTheWait(t *testing.T) {
545+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.SyncWait = 20 * time.Millisecond })
546+ other := name(t, "other")
547+ f.addRepo(other)
548+ release := make(chan struct{})
549+ entered := f.gatedPush(release)
550+
551+ res, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
552+ require.NoError(t, err)
553+ require.True(t, res.Queued, "the push is still running at the bound")
554+ require.Equal(t, f.repo, res.Repo)
555+ <-entered
556+
557+ all, err := f.m.SyncAll(context.Background(), domain.Root(), true)
558+ require.NoError(t, err)
559+ require.Len(t, all, 2)
560+ for _, r := range all {
561+ require.NoError(t, r.Err)
562+ require.True(t, r.Queued, "%s is coalesced or still running", r.Repo)
563+ require.Equal(t, r.Repo, r.Status.Repo)
564+ }
565+
566+ close(release)
567+ f.drain(t)
568+ require.Equal(t, domain.MirrorOK, f.statusOf(f.repo).State, "the sync completed in the background")
569+}
570+
571+func TestMirrorsSyncAllHonoursCallerCancellation(t *testing.T) {
572+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.SyncWait = time.Minute })
573+ release := make(chan struct{})
574+ entered := f.gatedPush(release)
575+ ctx, cancel := context.WithCancel(context.Background())
576+ errc := make(chan error, 1)
577+ go func() { _, err := f.m.SyncAll(ctx, domain.Root(), true); errc <- err }()
578+ <-entered
579+ cancel()
580+ require.ErrorIs(t, <-errc, context.Canceled)
581+ close(release)
582+}
@@ -490,3 +490,93 @@ func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) {
490 _, err = f.m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead))490 _, err = f.m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead))
491 require.ErrorIs(t, err, domain.ErrForbidden)491 require.ErrorIs(t, err, domain.ErrForbidden)
492 }492 }
493+
494+// gatedPush makes every push announce itself on entered and block until release is closed.
495+func (f *mirrorFixture) gatedPush(release <-chan struct{}) <-chan struct{} {
496+ entered := make(chan struct{}, 16)
497+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.MirrorPush) (ports.MirrorPushResult, error) {
498+ entered <- struct{}{}
499+ select {
500+ case <-release:
501+ case <-ctx.Done():
502+ }
503+ return ports.MirrorPushResult{}, nil
504+ }).Maybe()
505+ return entered
506+}
507+
508+func TestMirrorsSyncAllRunsRepositoriesConcurrently(t *testing.T) {
509+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers = 3; c.SyncWait = time.Minute })
510+ f.addRepo(name(t, "b"))
511+ f.addRepo(name(t, "c"))
512+ release := make(chan struct{})
513+ entered := f.gatedPush(release)
514+ type outcome struct {
515+ got []ports.MirrorSyncResult
516+ err error
517+ }
518+ done := make(chan outcome, 1)
519+ go func() {
520+ got, err := f.m.SyncAll(context.Background(), domain.Root(), true)
521+ done <- outcome{got, err}
522+ }()
523+ // All three pushes are in flight together: a sequential SyncAll would never get past the first.
524+ for i := 0; i < 3; i++ {
525+ select {
526+ case <-entered:
527+ case <-time.After(10 * time.Second):
528+ t.Fatalf("only %d pushes started: repositories are not synced concurrently", i)
529+ }
530+ }
531+ close(release)
532+ o := <-done
533+ require.NoError(t, o.err)
534+ require.Len(t, o.got, 3)
535+ for i, want := range []string{"tools", "b", "c"} {
536+ r := o.got[i]
537+ require.Equal(t, want, r.Repo.String(), "results keep the listing order")
538+ require.NoError(t, r.Err)
539+ require.False(t, r.Queued)
540+ require.Equal(t, domain.MirrorOK, r.Status.State)
541+ }
542+}
543+
544+func TestMirrorsSyncReturnsQueuedWhenPushOutlastsTheWait(t *testing.T) {
545+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.SyncWait = 20 * time.Millisecond })
546+ other := name(t, "other")
547+ f.addRepo(other)
548+ release := make(chan struct{})
549+ entered := f.gatedPush(release)
550+
551+ res, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
552+ require.NoError(t, err)
553+ require.True(t, res.Queued, "the push is still running at the bound")
554+ require.Equal(t, f.repo, res.Repo)
555+ <-entered
556+
557+ all, err := f.m.SyncAll(context.Background(), domain.Root(), true)
558+ require.NoError(t, err)
559+ require.Len(t, all, 2)
560+ for _, r := range all {
561+ require.NoError(t, r.Err)
562+ require.True(t, r.Queued, "%s is coalesced or still running", r.Repo)
563+ require.Equal(t, r.Repo, r.Status.Repo)
564+ }
565+
566+ close(release)
567+ f.drain(t)
568+ require.Equal(t, domain.MirrorOK, f.statusOf(f.repo).State, "the sync completed in the background")
569+}
570+
571+func TestMirrorsSyncAllHonoursCallerCancellation(t *testing.T) {
572+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.SyncWait = time.Minute })
573+ release := make(chan struct{})
574+ entered := f.gatedPush(release)
575+ ctx, cancel := context.WithCancel(context.Background())
576+ errc := make(chan error, 1)
577+ go func() { _, err := f.m.SyncAll(ctx, domain.Root(), true); errc <- err }()
578+ <-entered
579+ cancel()
580+ require.ErrorIs(t, <-errc, context.Canceled)
581+ close(release)
582+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close