detached 41a0ba6

feat(host): tell the user when annotations were dropped for lack of room

The host counts the annotations it drops over the shared budget (a coalesced
counter, no content) and, after the outbox is closed and before CompleteJob,
appends one warning line to the job log through the final-report path, with a
slice of the report budget of its own. The text is ermite's own: the official
runner has no reporting backlog (GO-009).
commit
41a0ba6bf883fb87802e50d54393398f997f5b7a
author
brice · · 2026-10-14 03:09 UTC
parent
349efbd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +119 -1
M internal/host/job.go +8 -1
@@ -77,6 +77,7 @@ type jobRun struct {
7777 // annSeen and sumSeen are the steps that sent their annotations and their
7878 // summary: each is sent once.
7979 annSeen, sumSeen map[int32]bool
80+ annDropped int // annotations dropped for lack of room, see reportDroppedAnnotations
8081 // hard stops the sandbox at once; set before the session starts.
8182 hard func()
8283 result *execpb.JobResult
@@ -582,6 +583,7 @@ func (j *jobRun) report(ctx context.Context, out outcome, shutdown <-chan struct
582583 if err := j.abandoned(); err != nil {
583584 return
584585 }
586+ j.reportDroppedAnnotations(rctx, budget/4)
585587 j.complete(rctx, out.conclusion, out.outputs, out.shutdown)
586588 }
587589
@@ -835,13 +837,18 @@ func (j *jobRun) sendComplete(ctx context.Context, req *ermite.CompleteJobReques
835837
836838 // errorLog appends a line to the log after the outbox is closed.
837839 func (j *jobRun) errorLog(ctx context.Context, msg string) {
840+ j.appendLogLine(ctx, "##[error]"+msg)
841+}
842+
843+// appendLogLine appends one line, in the last step, after the outbox is closed.
844+func (j *jobRun) appendLogLine(ctx context.Context, text string) {
838845 j.mu.Lock()
839846 var last int32 = 1
840847 for n := range j.steps {
841848 last = max(last, n)
842849 }
843850 j.mu.Unlock()
844- j.outbox.AppendNow(ctx, logLine{step: last, unixNano: time.Now().UnixNano(), text: "##[error]" + msg})
851+ j.outbox.AppendNow(ctx, logLine{step: last, unixNano: time.Now().UnixNano(), text: text})
845852 }
846853
847854 // rebuiltSteps returns every known step as a valid, completed snapshot: number
@@ -77,6 +77,7 @@ type jobRun struct {
77 // annSeen and sumSeen are the steps that sent their annotations and their77 // annSeen and sumSeen are the steps that sent their annotations and their
78 // summary: each is sent once.78 // summary: each is sent once.
79 annSeen, sumSeen map[int32]bool79 annSeen, sumSeen map[int32]bool
80+ annDropped int // annotations dropped for lack of room, see reportDroppedAnnotations
80 // hard stops the sandbox at once; set before the session starts.81 // hard stops the sandbox at once; set before the session starts.
81 hard func()82 hard func()
82 result *execpb.JobResult83 result *execpb.JobResult
@@ -582,6 +583,7 @@ func (j *jobRun) report(ctx context.Context, out outcome, shutdown <-chan struct
582 if err := j.abandoned(); err != nil {583 if err := j.abandoned(); err != nil {
583 return584 return
584 }585 }
586+ j.reportDroppedAnnotations(rctx, budget/4)
585 j.complete(rctx, out.conclusion, out.outputs, out.shutdown)587 j.complete(rctx, out.conclusion, out.outputs, out.shutdown)
586 }588 }
587 589
@@ -835,13 +837,18 @@ func (j *jobRun) sendComplete(ctx context.Context, req *ermite.CompleteJobReques
835 837
836 // errorLog appends a line to the log after the outbox is closed.838 // errorLog appends a line to the log after the outbox is closed.
837 func (j *jobRun) errorLog(ctx context.Context, msg string) {839 func (j *jobRun) errorLog(ctx context.Context, msg string) {
840+ j.appendLogLine(ctx, "##[error]"+msg)
841+}
842+
843+// appendLogLine appends one line, in the last step, after the outbox is closed.
844+func (j *jobRun) appendLogLine(ctx context.Context, text string) {
838 j.mu.Lock()845 j.mu.Lock()
839 var last int32 = 1846 var last int32 = 1
840 for n := range j.steps {847 for n := range j.steps {
841 last = max(last, n)848 last = max(last, n)
842 }849 }
843 j.mu.Unlock()850 j.mu.Unlock()
844- j.outbox.AppendNow(ctx, logLine{step: last, unixNano: time.Now().UnixNano(), text: "##[error]" + msg})851+ j.outbox.AppendNow(ctx, logLine{step: last, unixNano: time.Now().UnixNano(), text: text})
845 }852 }
846 853
847 // rebuiltSteps returns every known step as a valid, completed snapshot: number854 // rebuiltSteps returns every known step as a valid, completed snapshot: number
M internal/host/results.go +34 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "fmt"
88 "strings"
9+ "time"
910 "unicode/utf8"
1011
1112 "connectrpc.com/connect"
@@ -160,6 +161,8 @@ func (j *jobRun) stepAnnotations(a *execpb.StepAnnotations) {
160161 n := annotationBytes(list)
161162 if !j.outbox.ReserveAnnotations(n) {
162163 j.log.Warn().Int32("step", a.Step).Int("bytes", n).Msg("too much is waiting to be sent to the server: the annotations of this step are dropped")
164+ // Only a count is kept (j.mu is held): the final report tells the user.
165+ j.annDropped += len(list)
163166 return
164167 }
165168 j.annLists[a.Step] = list
@@ -254,3 +257,34 @@ func (j *jobRun) allPendingAnnotations() []*ermite.Annotation {
254257 j.mu.Unlock()
255258 return flattenAnnotations(m)
256259 }
260+
261+// droppedAnnotationsLine is the warning the user gets when annotations were
262+// dropped because the backlog of reports to the server was full. The text is
263+// ermite's own (the official runner has no such backlog); it carries no
264+// annotation content, only the count, which the host coalesces over the whole
265+// job. Empty when nothing was dropped.
266+func droppedAnnotationsLine(n int) string {
267+ switch {
268+ case n <= 0:
269+ return ""
270+ case n == 1:
271+ return "##[warning]1 annotation was not reported because the reporting backlog was full."
272+ }
273+ return fmt.Sprintf("##[warning]%d annotations were not reported because the reporting backlog was full.", n)
274+}
275+
276+// reportDroppedAnnotations appends the one warning line to the job log, after
277+// the outbox is closed (the queue is full, or gone: it is not used) and before
278+// CompleteJob, with a slice of the report budget of its own so that a server
279+// that keeps failing cannot take the time CompleteJob needs.
280+func (j *jobRun) reportDroppedAnnotations(ctx context.Context, budget time.Duration) {
281+ j.mu.Lock()
282+ line := droppedAnnotationsLine(j.annDropped)
283+ j.mu.Unlock()
284+ if line == "" {
285+ return
286+ }
287+ actx, cancel := context.WithTimeout(ctx, budget)
288+ defer cancel()
289+ j.appendLogLine(actx, line)
290+}
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "fmt"7 "fmt"
8 "strings"8 "strings"
9+ "time"
9 "unicode/utf8"10 "unicode/utf8"
10 11
11 "connectrpc.com/connect"12 "connectrpc.com/connect"
@@ -160,6 +161,8 @@ func (j *jobRun) stepAnnotations(a *execpb.StepAnnotations) {
160 n := annotationBytes(list)161 n := annotationBytes(list)
161 if !j.outbox.ReserveAnnotations(n) {162 if !j.outbox.ReserveAnnotations(n) {
162 j.log.Warn().Int32("step", a.Step).Int("bytes", n).Msg("too much is waiting to be sent to the server: the annotations of this step are dropped")163 j.log.Warn().Int32("step", a.Step).Int("bytes", n).Msg("too much is waiting to be sent to the server: the annotations of this step are dropped")
164+ // Only a count is kept (j.mu is held): the final report tells the user.
165+ j.annDropped += len(list)
163 return166 return
164 }167 }
165 j.annLists[a.Step] = list168 j.annLists[a.Step] = list
@@ -254,3 +257,34 @@ func (j *jobRun) allPendingAnnotations() []*ermite.Annotation {
254 j.mu.Unlock()257 j.mu.Unlock()
255 return flattenAnnotations(m)258 return flattenAnnotations(m)
256 }259 }
260+
261+// droppedAnnotationsLine is the warning the user gets when annotations were
262+// dropped because the backlog of reports to the server was full. The text is
263+// ermite's own (the official runner has no such backlog); it carries no
264+// annotation content, only the count, which the host coalesces over the whole
265+// job. Empty when nothing was dropped.
266+func droppedAnnotationsLine(n int) string {
267+ switch {
268+ case n <= 0:
269+ return ""
270+ case n == 1:
271+ return "##[warning]1 annotation was not reported because the reporting backlog was full."
272+ }
273+ return fmt.Sprintf("##[warning]%d annotations were not reported because the reporting backlog was full.", n)
274+}
275+
276+// reportDroppedAnnotations appends the one warning line to the job log, after
277+// the outbox is closed (the queue is full, or gone: it is not used) and before
278+// CompleteJob, with a slice of the report budget of its own so that a server
279+// that keeps failing cannot take the time CompleteJob needs.
280+func (j *jobRun) reportDroppedAnnotations(ctx context.Context, budget time.Duration) {
281+ j.mu.Lock()
282+ line := droppedAnnotationsLine(j.annDropped)
283+ j.mu.Unlock()
284+ if line == "" {
285+ return
286+ }
287+ actx, cancel := context.WithTimeout(ctx, budget)
288+ defer cancel()
289+ j.appendLogLine(actx, line)
290+}
M internal/host/results_test.go +77 -0
@@ -5,6 +5,7 @@ package host
55 import (
66 "context"
77 "errors"
8+ "fmt"
89 "io"
910 "strings"
1011 "sync/atomic"
@@ -557,3 +558,79 @@ func TestAnnotationBudgetIsGivenBack(t *testing.T) {
557558 return o.queued == 0
558559 })
559560 }
561+
562+// GO-009: annotations dropped for lack of room are told to the user with one
563+// warning line in the job log, appended after the outbox is closed, without
564+// annotation content.
565+func TestDroppedAnnotationsAreReportedInTheLog(t *testing.T) {
566+ var list []*execpb.Annotation
567+ for _, l := range []execpb.AnnotationLevel{lWarn, lFail, execpb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE} {
568+ for range 10 {
569+ list = append(list, &execpb.Annotation{Level: l, Message: "SECRET-CONTENT" + strings.Repeat("m", 4080), Title: strings.Repeat("t", 255)})
570+ }
571+ }
572+ const steps = 200 // about 135 KiB each: 27 MB offered, 16 MiB of room
573+ sc := func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) {
574+ for n := 1; n <= steps; n++ {
575+ stepStart(c, n, "step")
576+ annFrame(c, n, list...)
577+ stepDone(c, n, "step", cOK)
578+ }
579+ jobDone(c, cOK, nil, false)
580+ }
581+ h := newHarness(t, sc)
582+ h.runner.Timing.CompleteDeadline = 3 * time.Second
583+ // the server is blocked: every UpdateJob fails transiently, for ever
584+ h.srv.updateHook = func(int, *ermite.UpdateJobRequest) error { return connect.NewError(connect.CodeUnavailable, nil) }
585+ h.srv.jobs = []*ermite.Job{testJob("j1")}
586+ stop := h.run(t)
587+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
588+ if err := stop(); err != nil {
589+ t.Fatal(err)
590+ }
591+ h.srv.mu.Lock()
592+ defer h.srv.mu.Unlock()
593+ var found []string
594+ for _, l := range h.srv.lines {
595+ if strings.Contains(l, "were not reported because the reporting backlog was full.") {
596+ found = append(found, l)
597+ }
598+ if strings.Contains(l, "SECRET-CONTENT") {
599+ t.Error("annotation content reached the job log")
600+ }
601+ }
602+ if len(found) != 1 {
603+ t.Fatalf("%d lines about dropped annotations: %q", len(found), found)
604+ }
605+ var n int
606+ if _, err := fmt.Sscanf(found[0], "##[warning]%d annotations were not reported because the reporting backlog was full.", &n); err != nil || n < 30 || n > steps*30 || n%30 != 0 {
607+ t.Errorf("%q (n=%d, %v)", found[0], n, err)
608+ }
609+ if h.srv.lines[len(h.srv.lines)-1] != found[0] {
610+ t.Error("the line is not the last of the log")
611+ }
612+}
613+
614+func TestDroppedAnnotationsLine(t *testing.T) {
615+ for n, want := range map[int]string{
616+ 0: "", -1: "",
617+ 1: "##[warning]1 annotation was not reported because the reporting backlog was full.",
618+ 30: "##[warning]30 annotations were not reported because the reporting backlog was full.",
619+ } {
620+ if got := droppedAnnotationsLine(n); got != want {
621+ t.Errorf("%d: %q", n, got)
622+ }
623+ }
624+}
625+
626+// A job that drops nothing writes no such line.
627+func TestNoDroppedAnnotationsNoLine(t *testing.T) {
628+ h := runResultsJob(t, resultsScript(nil), nil)
629+ h.srv.mu.Lock()
630+ defer h.srv.mu.Unlock()
631+ for _, l := range h.srv.lines {
632+ if strings.Contains(l, "reporting backlog") {
633+ t.Errorf("%q", l)
634+ }
635+ }
636+}
@@ -5,6 +5,7 @@ package host
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8+ "fmt"
8 "io"9 "io"
9 "strings"10 "strings"
10 "sync/atomic"11 "sync/atomic"
@@ -557,3 +558,79 @@ func TestAnnotationBudgetIsGivenBack(t *testing.T) {
557 return o.queued == 0558 return o.queued == 0
558 })559 })
559 }560 }
561+
562+// GO-009: annotations dropped for lack of room are told to the user with one
563+// warning line in the job log, appended after the outbox is closed, without
564+// annotation content.
565+func TestDroppedAnnotationsAreReportedInTheLog(t *testing.T) {
566+ var list []*execpb.Annotation
567+ for _, l := range []execpb.AnnotationLevel{lWarn, lFail, execpb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE} {
568+ for range 10 {
569+ list = append(list, &execpb.Annotation{Level: l, Message: "SECRET-CONTENT" + strings.Repeat("m", 4080), Title: strings.Repeat("t", 255)})
570+ }
571+ }
572+ const steps = 200 // about 135 KiB each: 27 MB offered, 16 MiB of room
573+ sc := func(_ *testing.T, c *execstream.Conn, _ *execpb.Run, _ <-chan *execpb.Cancel) {
574+ for n := 1; n <= steps; n++ {
575+ stepStart(c, n, "step")
576+ annFrame(c, n, list...)
577+ stepDone(c, n, "step", cOK)
578+ }
579+ jobDone(c, cOK, nil, false)
580+ }
581+ h := newHarness(t, sc)
582+ h.runner.Timing.CompleteDeadline = 3 * time.Second
583+ // the server is blocked: every UpdateJob fails transiently, for ever
584+ h.srv.updateHook = func(int, *ermite.UpdateJobRequest) error { return connect.NewError(connect.CodeUnavailable, nil) }
585+ h.srv.jobs = []*ermite.Job{testJob("j1")}
586+ stop := h.run(t)
587+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
588+ if err := stop(); err != nil {
589+ t.Fatal(err)
590+ }
591+ h.srv.mu.Lock()
592+ defer h.srv.mu.Unlock()
593+ var found []string
594+ for _, l := range h.srv.lines {
595+ if strings.Contains(l, "were not reported because the reporting backlog was full.") {
596+ found = append(found, l)
597+ }
598+ if strings.Contains(l, "SECRET-CONTENT") {
599+ t.Error("annotation content reached the job log")
600+ }
601+ }
602+ if len(found) != 1 {
603+ t.Fatalf("%d lines about dropped annotations: %q", len(found), found)
604+ }
605+ var n int
606+ if _, err := fmt.Sscanf(found[0], "##[warning]%d annotations were not reported because the reporting backlog was full.", &n); err != nil || n < 30 || n > steps*30 || n%30 != 0 {
607+ t.Errorf("%q (n=%d, %v)", found[0], n, err)
608+ }
609+ if h.srv.lines[len(h.srv.lines)-1] != found[0] {
610+ t.Error("the line is not the last of the log")
611+ }
612+}
613+
614+func TestDroppedAnnotationsLine(t *testing.T) {
615+ for n, want := range map[int]string{
616+ 0: "", -1: "",
617+ 1: "##[warning]1 annotation was not reported because the reporting backlog was full.",
618+ 30: "##[warning]30 annotations were not reported because the reporting backlog was full.",
619+ } {
620+ if got := droppedAnnotationsLine(n); got != want {
621+ t.Errorf("%d: %q", n, got)
622+ }
623+ }
624+}
625+
626+// A job that drops nothing writes no such line.
627+func TestNoDroppedAnnotationsNoLine(t *testing.T) {
628+ h := runResultsJob(t, resultsScript(nil), nil)
629+ h.srv.mu.Lock()
630+ defer h.srv.mu.Unlock()
631+ for _, l := range h.srv.lines {
632+ if strings.Contains(l, "reporting backlog") {
633+ t.Errorf("%q", l)
634+ }
635+ }
636+}

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 4

Tags 11

↑↓ moveenter openesc close