Skip to content

Commit 295eecb

Browse files
silverwindsilverwind
authored andcommitted
fix: ensure dbfs_data is cleaned up after task completion (#952)
Fixes #950. After #819, the daemon flushes logs eagerly on the job-result entry (via the `stateNotify` path), so `Close()` typically runs `ReportLog(true)` with an empty buffer. Gitea's `UpdateLog` handler short-circuits on `len(Rows)==0` before honoring `NoMore`, so the final request never runs `TransferLogs` and `dbfs_data` rows leak. The server-side short-circuit is latent since the original Actions implementation in 2023; #819 made it deterministically reachable. Workaround: inject a sentinel row in `Close()` after the daemon has exited so the final `UpdateLog` always carries at least one row. Done after the daemon waits so the sentinel can't be flushed before `ReportLog(true)` reads it. go-gitea/gitea#37631 drops the empty-rows short-circuit when `NoMore=true`; that would work with or without this PR. Reviewed-on: https://gitea.com/gitea/runner/pulls/952 Reviewed-by: Nicolas <bircni@icloud.com> Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> Co-authored-by: silverwind <me@silverwind.io> Co-committed-by: silverwind <me@silverwind.io>
1 parent ef6ca95 commit 295eecb

2 files changed

Lines changed: 77 additions & 0 deletions

File tree

internal/pkg/report/reporter.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,21 @@ func (r *Reporter) Close(lastWords string) error {
416416
log.Error("No Response from RunDaemon for 60s, continue best effort")
417417
}
418418

419+
// Gitea's UpdateLog short-circuits on len(Rows)==0 before honoring NoMore,
420+
// so a final empty request never runs TransferLogs and dbfs_data leaks.
421+
// Inject a sentinel row after the daemon has exited so it can't be flushed
422+
// before ReportLog(true).
423+
// TODO: Remove after https://github.com/go-gitea/gitea/pull/37631 is in all
424+
// supported branches, e.g. v1.28+.
425+
r.stateMu.Lock()
426+
if len(r.logRows) == 0 {
427+
r.logRows = append(r.logRows, &runnerv1.LogRow{
428+
Time: timestamppb.Now(),
429+
Content: "",
430+
})
431+
}
432+
r.stateMu.Unlock()
433+
419434
// Report the job outcome even when all log upload retry attempts have been exhausted
420435
return errors.Join(
421436
retry.Do(func() error {

internal/pkg/report/reporter_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,68 @@ func TestReporter_StateNotifyFlush(t *testing.T) {
598598
"step transition should have triggered immediate state flush via stateNotify")
599599
}
600600

601+
// Regression test for https://gitea.com/gitea/runner/issues/950: Close() must
602+
// always send a final UpdateLog with NoMore=true carrying at least one row,
603+
// otherwise the server's len(Rows)==0 short-circuit skips TransferLogs.
604+
// TODO: Remove after https://github.com/go-gitea/gitea/pull/37631 is in all
605+
// supported branches, e.g. v1.28+.
606+
func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) {
607+
var lastReq atomic.Pointer[runnerv1.UpdateLogRequest]
608+
var noMoreCalls atomic.Int64
609+
610+
client := mocks.NewClient(t)
611+
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
612+
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
613+
lastReq.Store(req.Msg)
614+
if req.Msg.NoMore {
615+
noMoreCalls.Add(1)
616+
}
617+
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
618+
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
619+
}), nil
620+
},
621+
)
622+
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
623+
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
624+
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
625+
},
626+
)
627+
628+
ctx, cancel := context.WithCancel(context.Background())
629+
defer cancel()
630+
taskCtx, err := structpb.NewStruct(map[string]any{})
631+
require.NoError(t, err)
632+
633+
// Intervals large enough that no daemon-driven flush fires during the test.
634+
cfg, _ := config.LoadDefault("")
635+
cfg.Runner.LogReportInterval = 10 * time.Second
636+
cfg.Runner.LogReportMaxLatency = 10 * time.Second
637+
cfg.Runner.StateReportInterval = 10 * time.Second
638+
cfg.Runner.LogReportBatchSize = 1000
639+
640+
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
641+
reporter.ResetSteps(1)
642+
reporter.RunDaemon()
643+
644+
// Simulate a successful job whose log buffer was already drained by the
645+
// daemon (logOffset > 0, logRows empty, terminal Result set). This is the
646+
// state Close() lands in for the typical successful job under #819.
647+
reporter.stateMu.Lock()
648+
reporter.logOffset = 5
649+
reporter.state.Result = runnerv1.Result_RESULT_SUCCESS
650+
reporter.state.Steps[0].Result = runnerv1.Result_RESULT_SUCCESS
651+
reporter.state.StoppedAt = timestamppb.Now()
652+
reporter.stateMu.Unlock()
653+
654+
require.NoError(t, reporter.Close(""))
655+
656+
require.Equal(t, int64(1), noMoreCalls.Load(), "Close must send exactly one UpdateLog with NoMore=true")
657+
final := lastReq.Load()
658+
require.NotNil(t, final)
659+
assert.True(t, final.NoMore, "final UpdateLog must carry NoMore=true")
660+
assert.NotEmpty(t, final.Rows, "final UpdateLog must carry at least one row")
661+
}
662+
601663
// TestReporter_StateHeartbeat verifies that ReportState sends a heartbeat
602664
// UpdateTask once stateReportInterval has elapsed since the last successful
603665
// report, even when nothing has changed. Without this, long-running silent

0 commit comments

Comments
 (0)