This is an automated email from the ASF dual-hosted git repository.

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 2bcc408bb fix(circleci): limit unfinished details recollection to 
recent workflows (#9304) (#9309)
2bcc408bb is described below

commit 2bcc408bb169b5af83e05f4a35437c8e66fe880f
Author: Volodymyr Zahorniak <[email protected]>
AuthorDate: Tue Nov 3 09:12:35 2026 +0200

    fix(circleci): limit unfinished details recollection to recent workflows 
(#9304) (#9309)
    
    The unfinished-details collectors of collectJobs and collectWorkflows 
selected every
    workflow in a non-terminal status. A workflow whose approval job is never 
approved stays
    on_hold forever, so it was collected again on every run and the raw tables 
kept growing.
    Select only workflows created within the last 30 days.
---
 backend/plugins/circleci/e2e/job_collector_test.go | 97 ++++++++++++++++------
 backend/plugins/circleci/tasks/job_collector.go    | 17 ++--
 backend/plugins/circleci/tasks/shared.go           | 11 +++
 .../plugins/circleci/tasks/workflow_collector.go   | 22 +++--
 4 files changed, 108 insertions(+), 39 deletions(-)

diff --git a/backend/plugins/circleci/e2e/job_collector_test.go 
b/backend/plugins/circleci/e2e/job_collector_test.go
index f3988b48a..f27963e0c 100644
--- a/backend/plugins/circleci/e2e/job_collector_test.go
+++ b/backend/plugins/circleci/e2e/job_collector_test.go
@@ -21,7 +21,10 @@ import (
        "reflect"
        "sort"
        "testing"
+       "time"
 
+       "github.com/apache/devlake/core/dal"
+       "github.com/apache/devlake/core/models/common"
        "github.com/apache/devlake/helpers/e2ehelper"
        "github.com/apache/devlake/helpers/pluginhelper/api"
        "github.com/apache/devlake/plugins/circleci/impl"
@@ -30,36 +33,33 @@ import (
        "github.com/stretchr/testify/assert"
 )
 
-// TestCircleciUnfinishedJobsInputIterator is a regression test for
-// https://github.com/apache/devlake/issues/8907. The "collect unfinished job
-// details" collector builds its URL from "/v2/workflow/{{ .Input.Id }}/job" 
while
-// scanning rows into a models.CircleciJob. Its input query must therefore 
expose the
-// workflow id in the row's Id field; a bare "DISTINCT workflow_id" left Id 
empty and
-// produced "/v2/workflow//job" (HTTP 500). This test runs the production query
-// (tasks.UnfinishedJobsInputClauses) through the real iterator and asserts 
each
-// yielded row's Id is the workflow id, that results are DISTINCT, and that the
-// status/connection filters hold.
-func TestCircleciUnfinishedJobsInputIterator(t *testing.T) {
-       var circleci impl.Circleci
-       dataflowTester := e2ehelper.NewDataFlowTester(t, "circleci", circleci)
+const unfinishedTestProjectSlug = "github/test/repo"
 
-       const projectSlug = "github/test/repo"
-       dataflowTester.FlushTabler(&models.CircleciJob{})
+var unfinishedTestNow = time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
 
-       seed := []models.CircleciJob{
-               {ConnectionId: 1, WorkflowId: "wf-onhold", Id: "job-1", 
ProjectSlug: projectSlug, Status: "on_hold"},
-               {ConnectionId: 1, WorkflowId: "wf-onhold", Id: "job-2", 
ProjectSlug: projectSlug, Status: "running"}, // same workflow -> DISTINCT
-               {ConnectionId: 1, WorkflowId: "wf-queued", Id: "job-3", 
ProjectSlug: projectSlug, Status: "queued"},
-               {ConnectionId: 1, WorkflowId: "wf-success", Id: "job-4", 
ProjectSlug: projectSlug, Status: "success"},   // terminal -> excluded
-               {ConnectionId: 2, WorkflowId: "wf-otherconn", Id: "job-5", 
ProjectSlug: projectSlug, Status: "on_hold"}, // other connection -> excluded
+func daysAgo(days int) *common.Iso8601Time {
+       return &common.Iso8601Time{Time: unfinishedTestNow.AddDate(0, 0, -days)}
+}
+
+func seedUnfinishedWorkflows(t *testing.T, dataflowTester 
*e2ehelper.DataFlowTester) {
+       dataflowTester.FlushTabler(&models.CircleciWorkflow{})
+       seed := []models.CircleciWorkflow{
+               {ConnectionId: 1, Id: "wf-onhold", ProjectSlug: 
unfinishedTestProjectSlug, Status: "on_hold", CreatedDate: daysAgo(1)},
+               {ConnectionId: 1, Id: "wf-queued", ProjectSlug: 
unfinishedTestProjectSlug, Status: "running", CreatedDate: daysAgo(29)},
+               {ConnectionId: 1, Id: "wf-failing", ProjectSlug: 
unfinishedTestProjectSlug, Status: "failing", CreatedDate: daysAgo(2)},
+               {ConnectionId: 1, Id: "wf-success", ProjectSlug: 
unfinishedTestProjectSlug, Status: "success", CreatedDate: daysAgo(1)},
+               {ConnectionId: 1, Id: "wf-old-onhold", ProjectSlug: 
unfinishedTestProjectSlug, Status: "on_hold", CreatedDate: daysAgo(31)},
+               {ConnectionId: 2, Id: "wf-otherconn", ProjectSlug: 
unfinishedTestProjectSlug, Status: "on_hold", CreatedDate: daysAgo(1)},
        }
        for i := range seed {
                assert.Nil(t, dataflowTester.Dal.Create(&seed[i]))
        }
+}
 
-       cursor, err := 
dataflowTester.Dal.Cursor(tasks.UnfinishedJobsInputClauses(1, projectSlug)...)
+func collectInputIds(t *testing.T, dataflowTester *e2ehelper.DataFlowTester, 
clauses []dal.Clause, rowType reflect.Type) []string {
+       cursor, err := dataflowTester.Dal.Cursor(clauses...)
        assert.Nil(t, err)
-       iter, err := api.NewDalCursorIterator(dataflowTester.Dal, cursor, 
reflect.TypeOf(models.CircleciJob{}))
+       iter, err := api.NewDalCursorIterator(dataflowTester.Dal, cursor, 
rowType)
        assert.Nil(t, err)
        defer iter.Close()
 
@@ -67,16 +67,61 @@ func TestCircleciUnfinishedJobsInputIterator(t *testing.T) {
        for iter.HasNext() {
                item, err := iter.Fetch()
                assert.Nil(t, err)
-               job := item.(*models.CircleciJob)
-               ids = append(ids, job.Id)
+               ids = append(ids, 
reflect.ValueOf(item).Elem().FieldByName("Id").String())
        }
        sort.Strings(ids)
+       return ids
+}
+
+// TestCircleciUnfinishedJobsInputIterator is a regression test for
+// https://github.com/apache/devlake/issues/8907 and
+// https://github.com/apache/devlake/issues/9304. The "collect unfinished job
+// details" collector builds its URL from "/v2/workflow/{{ .Input.Id }}/job" 
while
+// scanning rows into a models.CircleciJob. Its input query must therefore 
expose the
+// workflow id in the row's Id field; a bare "DISTINCT workflow_id" left Id 
empty and
+// produced "/v2/workflow//job" (HTTP 500). It must also skip workflows older 
than the
+// lookback window: an approval job that is never approved stays on_hold 
forever, and
+// its workflow was collected again on every run (#9304).
+func TestCircleciUnfinishedJobsInputIterator(t *testing.T) {
+       var circleci impl.Circleci
+       dataflowTester := e2ehelper.NewDataFlowTester(t, "circleci", circleci)
+
+       seedUnfinishedWorkflows(t, dataflowTester)
+       dataflowTester.FlushTabler(&models.CircleciJob{})
+       seed := []models.CircleciJob{
+               {ConnectionId: 1, WorkflowId: "wf-onhold", Id: "job-1", 
ProjectSlug: unfinishedTestProjectSlug, Status: "on_hold"},
+               {ConnectionId: 1, WorkflowId: "wf-onhold", Id: "job-2", 
ProjectSlug: unfinishedTestProjectSlug, Status: "running"}, // same workflow -> 
DISTINCT
+               {ConnectionId: 1, WorkflowId: "wf-queued", Id: "job-3", 
ProjectSlug: unfinishedTestProjectSlug, Status: "queued"},
+               {ConnectionId: 1, WorkflowId: "wf-success", Id: "job-4", 
ProjectSlug: unfinishedTestProjectSlug, Status: "success"},    // terminal -> 
excluded
+               {ConnectionId: 1, WorkflowId: "wf-old-onhold", Id: "job-5", 
ProjectSlug: unfinishedTestProjectSlug, Status: "on_hold"}, // older than 
lookback -> excluded
+               {ConnectionId: 2, WorkflowId: "wf-otherconn", Id: "job-6", 
ProjectSlug: unfinishedTestProjectSlug, Status: "on_hold"},  // other 
connection -> excluded
+       }
+       for i := range seed {
+               assert.Nil(t, dataflowTester.Dal.Create(&seed[i]))
+       }
+
+       clauses := tasks.UnfinishedJobsInputClauses(1, 
unfinishedTestProjectSlug, 
tasks.UnfinishedDetailsCreatedAfter(unfinishedTestNow))
+       ids := collectInputIds(t, dataflowTester, clauses, 
reflect.TypeOf(models.CircleciJob{}))
 
        // Distinct workflow ids for connection 1's non-terminal jobs, with Id 
populated
-       // (the URL template reads .Input.Id). wf-success (terminal) and 
wf-otherconn
-       // (connection 2) are excluded.
+       // (the URL template reads .Input.Id).
        assert.Equal(t, []string{"wf-onhold", "wf-queued"}, ids)
        for _, id := range ids {
                assert.NotEmpty(t, id, "Input.Id must be the workflow id, not 
empty (#8907)")
        }
 }
+
+// TestCircleciUnfinishedWorkflowsInputIterator is a regression test for
+// https://github.com/apache/devlake/issues/9304: the "collect unfinished 
workflow
+// details" collector must skip workflows older than the lookback window.
+func TestCircleciUnfinishedWorkflowsInputIterator(t *testing.T) {
+       var circleci impl.Circleci
+       dataflowTester := e2ehelper.NewDataFlowTester(t, "circleci", circleci)
+
+       seedUnfinishedWorkflows(t, dataflowTester)
+
+       clauses := tasks.UnfinishedWorkflowsInputClauses(1, 
unfinishedTestProjectSlug, 
tasks.UnfinishedDetailsCreatedAfter(unfinishedTestNow))
+       ids := collectInputIds(t, dataflowTester, clauses, 
reflect.TypeOf(models.CircleciWorkflow{}))
+
+       assert.Equal(t, []string{"wf-failing", "wf-onhold", "wf-queued"}, ids)
+}
diff --git a/backend/plugins/circleci/tasks/job_collector.go 
b/backend/plugins/circleci/tasks/job_collector.go
index 2e950107a..730edaa9c 100644
--- a/backend/plugins/circleci/tasks/job_collector.go
+++ b/backend/plugins/circleci/tasks/job_collector.go
@@ -43,14 +43,16 @@ var CollectJobsMeta = plugin.SubTaskMeta{
 
 // UnfinishedJobsInputClauses returns the DAL clauses that select the 
workflows whose
 // jobs are still in a non-terminal status and therefore need their job details
-// recollected by the CollectJobs "unfinished details" collector.
-func UnfinishedJobsInputClauses(connectionId uint64, projectSlug string) 
[]dal.Clause {
+// recollected by the CollectJobs "unfinished details" collector. Only 
workflows created
+// at or after createdAfter are selected (#9304); jobs have no created date of 
their own.
+func UnfinishedJobsInputClauses(connectionId uint64, projectSlug string, 
createdAfter time.Time) []dal.Clause {
        return []dal.Clause{
-               dal.Select("DISTINCT workflow_id AS id"), // #8907: alias to id 
so {{ .Input.Id }} resolves when scanned into CircleciJob
-               dal.From(&models.CircleciJob{}),
+               dal.Select("DISTINCT j.workflow_id AS id"), // #8907: alias to 
id so {{ .Input.Id }} resolves when scanned into CircleciJob
+               dal.From("_tool_circleci_jobs j"),
+               dal.Join("JOIN _tool_circleci_workflows w ON w.connection_id = 
j.connection_id AND w.id = j.workflow_id"),
                dal.Where(
-                       "connection_id = ? AND project_slug = ? AND status IN 
('running', 'not_running', 'queued', 'on_hold')",
-                       connectionId, projectSlug,
+                       "j.connection_id = ? AND j.project_slug = ? AND 
j.status IN ('running', 'not_running', 'queued', 'on_hold') AND w.created_date 
>= ?",
+                       connectionId, projectSlug, createdAfter,
                ),
        }
 }
@@ -111,7 +113,8 @@ func CollectJobs(taskCtx plugin.SubTaskContext) 
errors.Error {
                        },
                        BuildInputIterator: func() (api.Iterator, errors.Error) 
{
                                db := taskCtx.GetDal()
-                               cursor, err := 
db.Cursor(UnfinishedJobsInputClauses(data.Options.ConnectionId, 
data.Options.ProjectSlug)...)
+                               clauses := 
UnfinishedJobsInputClauses(data.Options.ConnectionId, data.Options.ProjectSlug, 
UnfinishedDetailsCreatedAfter(time.Now()))
+                               cursor, err := db.Cursor(clauses...)
                                if err != nil {
                                        return nil, err
                                }
diff --git a/backend/plugins/circleci/tasks/shared.go 
b/backend/plugins/circleci/tasks/shared.go
index f3be144dd..69a92db0d 100644
--- a/backend/plugins/circleci/tasks/shared.go
+++ b/backend/plugins/circleci/tasks/shared.go
@@ -31,6 +31,17 @@ import (
        "github.com/apache/devlake/plugins/circleci/models"
 )
 
+// UnfinishedDetailsLookback limits how far back the "unfinished details" 
collectors look.
+// A workflow with an approval job that is never approved stays on_hold 
forever; without
+// a limit it would be collected again on every run (#9304).
+const UnfinishedDetailsLookback = 30 * 24 * time.Hour
+
+// UnfinishedDetailsCreatedAfter returns the earliest created date of a 
workflow that the
+// "unfinished details" collectors still collect again.
+func UnfinishedDetailsCreatedAfter(now time.Time) time.Time {
+       return now.Add(-UnfinishedDetailsLookback)
+}
+
 var accountIdGen *didgen.DomainIdGenerator
 var projectIdGen *didgen.DomainIdGenerator
 var pipelineIdGen *didgen.DomainIdGenerator
diff --git a/backend/plugins/circleci/tasks/workflow_collector.go 
b/backend/plugins/circleci/tasks/workflow_collector.go
index 7847cfe22..024917a85 100644
--- a/backend/plugins/circleci/tasks/workflow_collector.go
+++ b/backend/plugins/circleci/tasks/workflow_collector.go
@@ -42,6 +42,21 @@ var CollectWorkflowsMeta = plugin.SubTaskMeta{
        DomainTypes:      []string{plugin.DOMAIN_TYPE_CICD},
 }
 
+// UnfinishedWorkflowsInputClauses returns the DAL clauses that select the 
workflows that
+// are still in a non-terminal status and therefore need their details 
recollected by the
+// CollectWorkflows "unfinished details" collector. Only workflows created at 
or after
+// createdAfter are selected (#9304).
+func UnfinishedWorkflowsInputClauses(connectionId uint64, projectSlug string, 
createdAfter time.Time) []dal.Clause {
+       return []dal.Clause{
+               dal.Select("id"),
+               dal.From(&models.CircleciWorkflow{}),
+               dal.Where(
+                       "connection_id = ? AND project_slug = ? AND status IN 
('running', 'on_hold', 'failing') AND created_date >= ?",
+                       connectionId, projectSlug, createdAfter,
+               ),
+       }
+}
+
 func CollectWorkflows(taskCtx plugin.SubTaskContext) errors.Error {
        rawDataSubTaskArgs, data := CreateRawDataSubTaskArgs(taskCtx, 
RAW_WORKFLOW_TABLE)
        logger := taskCtx.GetLogger()
@@ -93,12 +108,7 @@ func CollectWorkflows(taskCtx plugin.SubTaskContext) 
errors.Error {
                                AfterResponse: ignoreDeletedOrBrokenBuilds,
                        },
                        BuildInputIterator: func() (api.Iterator, errors.Error) 
{
-                               clauses := []dal.Clause{
-                                       dal.Select("id"),
-                                       dal.From(&models.CircleciWorkflow{}),
-                                       dal.Where("connection_id = ? AND 
project_slug = ? AND status IN ('running', 'on_hold', 'failing')", 
data.Options.ConnectionId, data.Options.ProjectSlug),
-                               }
-
+                               clauses := 
UnfinishedWorkflowsInputClauses(data.Options.ConnectionId, 
data.Options.ProjectSlug, UnfinishedDetailsCreatedAfter(time.Now()))
                                db := taskCtx.GetDal()
                                cursor, err := db.Cursor(clauses...)
                                if err != nil {

Reply via email to