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 6cfd55b60 refactor(circleci): make job and workflow extractors 
incremental (#9305) (#9313)
6cfd55b60 is described below

commit 6cfd55b60c64ac39b1cb6db3d1503ad8c876e7d6
Author: Volodymyr Zahorniak <[email protected]>
AuthorDate: Fri Nov 6 09:22:54 2026 +0200

    refactor(circleci): make job and workflow extractors incremental (#9305) 
(#9313)
    
    Use api.NewStatefulApiExtractor for extractJobs and extractWorkflows, so an 
incremental run reads only the raw rows added since the previous run.
---
 .../circleci/e2e/extractor_incremental_test.go     | 185 +++++++++++++++++++++
 backend/plugins/circleci/tasks/job_extractor.go    |  24 +--
 .../plugins/circleci/tasks/workflow_extractor.go   |  26 +--
 3 files changed, 211 insertions(+), 24 deletions(-)

diff --git a/backend/plugins/circleci/e2e/extractor_incremental_test.go 
b/backend/plugins/circleci/e2e/extractor_incremental_test.go
new file mode 100644
index 000000000..07ccf17d1
--- /dev/null
+++ b/backend/plugins/circleci/e2e/extractor_incremental_test.go
@@ -0,0 +1,185 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package e2e
+
+import (
+       "context"
+       "encoding/json"
+       "testing"
+       "time"
+
+       coremodels "github.com/apache/devlake/core/models"
+       "github.com/apache/devlake/core/models/common"
+       "github.com/apache/devlake/core/plugin"
+       "github.com/apache/devlake/core/runner"
+       "github.com/apache/devlake/helpers/e2ehelper"
+       "github.com/apache/devlake/helpers/pluginhelper/api"
+       contextimpl "github.com/apache/devlake/impls/context"
+       "github.com/apache/devlake/plugins/circleci/impl"
+       "github.com/apache/devlake/plugins/circleci/models"
+       "github.com/apache/devlake/plugins/circleci/tasks"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// TestCircleciJobExtractorIncremental checks that an incremental run of 
extractJobs
+// reads only the raw rows added since the previous run (#9305).
+func TestCircleciJobExtractorIncremental(t *testing.T) {
+       var circleci impl.Circleci
+       dataflowTester := e2ehelper.NewDataFlowTester(t, "circleci", circleci)
+       taskData := &tasks.CircleciTaskData{
+               Options: &tasks.CircleciOptions{
+                       ConnectionId: 1,
+                       ProjectSlug:  "github/coldgust/coldgust.github.io",
+               },
+       }
+       beginStatefulScenario(dataflowTester)
+       
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_circleci_api_jobs.csv", 
"_raw_circleci_api_jobs")
+       dataflowTester.FlushTabler(&models.CircleciJob{})
+
+       runSubtaskPreservingState(t, dataflowTester, tasks.ExtractJobsMeta, 
taskData)
+       dataflowTester.VerifyTableWithOptions(
+               models.CircleciJob{},
+               e2ehelper.TableOptions{
+                       CSVRelPath:  
"./snapshot_tables/_tool_circleci_jobs.csv",
+                       IgnoreTypes: []interface{}{common.NoPKModel{}},
+               },
+       )
+
+       const removedJob = "7b96e45b-b10e-47a0-95d0-96580b88bdda"
+       const updatedJob = "ab8c3282-0e74-4a41-834e-152a71280bed"
+       require.NoError(t, dataflowTester.Db.Where("id = ?", 
removedJob).Delete(&models.CircleciJob{}).Error)
+       appendUpdatedRawRow(t, dataflowTester, "_raw_circleci_api_jobs", 20, 
map[string]any{"status": "success"})
+
+       runSubtaskPreservingState(t, dataflowTester, tasks.ExtractJobsMeta, 
taskData)
+       statuses := jobStatuses(t, dataflowTester)
+       assert.Len(t, statuses, 8)
+       assert.NotContains(t, statuses, removedJob, "an incremental run must 
not read old raw rows again")
+       assert.Equal(t, "success", statuses[updatedJob])
+
+       dataflowTester.Subtask(tasks.ExtractJobsMeta, taskData)
+       statuses = jobStatuses(t, dataflowTester)
+       assert.Len(t, statuses, 9)
+       assert.Contains(t, statuses, removedJob, "a full sync must read all raw 
rows")
+       assert.Equal(t, "success", statuses[updatedJob], "the newest raw row 
must win")
+}
+
+// TestCircleciWorkflowExtractorIncremental checks that an incremental run of
+// extractWorkflows reads only the raw rows added since the previous run 
(#9305).
+func TestCircleciWorkflowExtractorIncremental(t *testing.T) {
+       var circleci impl.Circleci
+       dataflowTester := e2ehelper.NewDataFlowTester(t, "circleci", circleci)
+       taskData := &tasks.CircleciTaskData{
+               Options: &tasks.CircleciOptions{
+                       ConnectionId: 1,
+                       ProjectSlug:  "github/coldgust/coldgust.github.io",
+               },
+       }
+       beginStatefulScenario(dataflowTester)
+       
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_circleci_api_workflows.csv",
 "_raw_circleci_api_workflows")
+       dataflowTester.FlushTabler(&models.CircleciWorkflow{})
+
+       runSubtaskPreservingState(t, dataflowTester, 
tasks.ExtractWorkflowsMeta, taskData)
+       dataflowTester.VerifyTableWithOptions(
+               models.CircleciWorkflow{},
+               e2ehelper.TableOptions{
+                       CSVRelPath:   
"./snapshot_tables/_tool_circleci_workflows.csv",
+                       IgnoreTypes:  []interface{}{common.NoPKModel{}},
+                       IgnoreFields: []string{"started_at", "stopped_at"},
+               },
+       )
+
+       const removedWorkflow = "89054eb2-8e85-4f5c-9a93-66d753a0e970"
+       const updatedWorkflow = "6731159f-5275-4bfa-ba70-39d343d63814"
+       require.NoError(t, dataflowTester.Db.Where("id = ?", 
removedWorkflow).Delete(&models.CircleciWorkflow{}).Error)
+       appendUpdatedRawRow(t, dataflowTester, "_raw_circleci_api_workflows", 
19, map[string]any{"status": "success"})
+
+       runSubtaskPreservingState(t, dataflowTester, 
tasks.ExtractWorkflowsMeta, taskData)
+       statuses := workflowStatuses(t, dataflowTester)
+       assert.Len(t, statuses, 9)
+       assert.NotContains(t, statuses, removedWorkflow, "an incremental run 
must not read old raw rows again")
+       assert.Equal(t, "success", statuses[updatedWorkflow])
+
+       dataflowTester.Subtask(tasks.ExtractWorkflowsMeta, taskData)
+       statuses = workflowStatuses(t, dataflowTester)
+       assert.Len(t, statuses, 10)
+       assert.Contains(t, statuses, removedWorkflow, "a full sync must read 
all raw rows")
+       assert.Equal(t, "success", statuses[updatedWorkflow], "the newest raw 
row must win")
+}
+
+// beginStatefulScenario removes the stored states, so that the first 
extractor run is a full sync.
+func beginStatefulScenario(dataflowTester *e2ehelper.DataFlowTester) {
+       dataflowTester.FlushTabler(&coremodels.SubtaskState{})
+       dataflowTester.FlushTabler(&coremodels.CollectorLatestState{})
+}
+
+// runSubtaskPreservingState runs a subtask without the forced full sync of 
DataFlowTester.Subtask.
+func runSubtaskPreservingState(t *testing.T, dataflowTester 
*e2ehelper.DataFlowTester, subtaskMeta plugin.SubTaskMeta, taskData 
interface{}) {
+       t.Helper()
+       subtaskCtx := contextimpl.NewStandaloneSubTaskContext(
+               context.Background(),
+               runner.CreateBasicRes(dataflowTester.Cfg, dataflowTester.Log, 
dataflowTester.Db),
+               dataflowTester.Name,
+               taskData,
+               subtaskMeta.Name,
+               &coremodels.SyncPolicy{},
+       )
+       require.NoError(t, subtaskMeta.EntryPoint(subtaskCtx))
+}
+
+// appendUpdatedRawRow adds a new raw row, created now, with the data of an 
existing row and the given changes.
+func appendUpdatedRawRow(t *testing.T, dataflowTester 
*e2ehelper.DataFlowTester, table string, sourceId uint64, changes 
map[string]any) {
+       t.Helper()
+       row := &api.RawData{}
+       require.NoError(t, dataflowTester.Db.Table(table).First(row, 
sourceId).Error)
+       data := map[string]any{}
+       require.NoError(t, json.Unmarshal(row.Data, &data))
+       for k, v := range changes {
+               data[k] = v
+       }
+       var err error
+       row.Data, err = json.Marshal(data)
+       require.NoError(t, err)
+       var maxId uint64
+       require.NoError(t, 
dataflowTester.Db.Table(table).Select("COALESCE(MAX(id), 
0)").Row().Scan(&maxId))
+       row.ID = maxId + 1
+       row.CreatedAt = time.Now()
+       require.NoError(t, dataflowTester.Db.Table(table).Create(row).Error)
+}
+
+func jobStatuses(t *testing.T, dataflowTester *e2ehelper.DataFlowTester) 
map[string]string {
+       t.Helper()
+       var jobs []models.CircleciJob
+       require.NoError(t, dataflowTester.Db.Where("connection_id = ?", 
1).Find(&jobs).Error)
+       statuses := make(map[string]string, len(jobs))
+       for _, j := range jobs {
+               statuses[j.Id] = j.Status
+       }
+       return statuses
+}
+
+func workflowStatuses(t *testing.T, dataflowTester *e2ehelper.DataFlowTester) 
map[string]string {
+       t.Helper()
+       var workflows []models.CircleciWorkflow
+       require.NoError(t, dataflowTester.Db.Where("connection_id = ?", 
1).Find(&workflows).Error)
+       statuses := make(map[string]string, len(workflows))
+       for _, w := range workflows {
+               statuses[w.Id] = w.Status
+       }
+       return statuses
+}
diff --git a/backend/plugins/circleci/tasks/job_extractor.go 
b/backend/plugins/circleci/tasks/job_extractor.go
index 8d65b46ed..4209ead43 100644
--- a/backend/plugins/circleci/tasks/job_extractor.go
+++ b/backend/plugins/circleci/tasks/job_extractor.go
@@ -36,21 +36,23 @@ var ExtractJobsMeta = plugin.SubTaskMeta{
 }
 
 func ExtractJobs(taskCtx plugin.SubTaskContext) errors.Error {
-       rawDataSubTaskArgs, data := CreateRawDataSubTaskArgs(taskCtx, 
RAW_JOB_TABLE)
-       extractor, err := api.NewApiExtractor(api.ApiExtractorArgs{
-               RawDataSubTaskArgs: *rawDataSubTaskArgs,
-               Extract: func(row *api.RawData) ([]interface{}, errors.Error) {
+       data := taskCtx.GetData().(*CircleciTaskData)
+       extractor, err := 
api.NewStatefulApiExtractor(&api.StatefulApiExtractorArgs[models.CircleciJob]{
+               SubtaskCommonArgs: &api.SubtaskCommonArgs{
+                       SubTaskContext: taskCtx,
+                       Table:          RAW_JOB_TABLE,
+                       Params: models.CircleciApiParams{
+                               ConnectionId: data.Options.ConnectionId,
+                               ProjectSlug:  data.Options.ProjectSlug,
+                       },
+               },
+               Extract: func(userRes *models.CircleciJob, row *api.RawData) 
([]any, errors.Error) {
                        input := &models.CircleciWorkflow{}
                        err := errors.Convert(json.Unmarshal(row.Input, input))
                        if err != nil {
                                return nil, err
                        }
-                       userRes := models.CircleciJob{}
-                       err = errors.Convert(json.Unmarshal(row.Data, &userRes))
-                       if err != nil {
-                               return nil, err
-                       }
-                       toolL := userRes
+                       toolL := *userRes
                        toolL.ConnectionId = data.Options.ConnectionId
                        toolL.ProjectSlug = data.Options.ProjectSlug
                        toolL.WorkflowId = input.Id
@@ -60,7 +62,7 @@ func ExtractJobs(taskCtx plugin.SubTaskContext) errors.Error {
                                stopTime := userRes.StoppedAt.ToTime()
                                toolL.DurationSec = 
float64(stopTime.Sub(startTime).Milliseconds() / 1e3)
                        }
-                       return []interface{}{
+                       return []any{
                                &toolL,
                        }, nil
                },
diff --git a/backend/plugins/circleci/tasks/workflow_extractor.go 
b/backend/plugins/circleci/tasks/workflow_extractor.go
index b61e95069..8500d701e 100644
--- a/backend/plugins/circleci/tasks/workflow_extractor.go
+++ b/backend/plugins/circleci/tasks/workflow_extractor.go
@@ -18,8 +18,6 @@ limitations under the License.
 package tasks
 
 import (
-       "encoding/json"
-
        "github.com/apache/devlake/core/errors"
        "github.com/apache/devlake/core/plugin"
        "github.com/apache/devlake/helpers/pluginhelper/api"
@@ -37,16 +35,18 @@ var ExtractWorkflowsMeta = plugin.SubTaskMeta{
 }
 
 func ExtractWorkflows(taskCtx plugin.SubTaskContext) errors.Error {
-       rawDataSubTaskArgs, data := CreateRawDataSubTaskArgs(taskCtx, 
RAW_WORKFLOW_TABLE)
-       extractor, err := api.NewApiExtractor(api.ApiExtractorArgs{
-               RawDataSubTaskArgs: *rawDataSubTaskArgs,
-               Extract: func(row *api.RawData) ([]interface{}, errors.Error) {
-                       userRes := models.CircleciWorkflow{}
-                       err := errors.Convert(json.Unmarshal(row.Data, 
&userRes))
-                       if err != nil {
-                               return nil, err
-                       }
-                       toolL := userRes
+       data := taskCtx.GetData().(*CircleciTaskData)
+       extractor, err := 
api.NewStatefulApiExtractor(&api.StatefulApiExtractorArgs[models.CircleciWorkflow]{
+               SubtaskCommonArgs: &api.SubtaskCommonArgs{
+                       SubTaskContext: taskCtx,
+                       Table:          RAW_WORKFLOW_TABLE,
+                       Params: models.CircleciApiParams{
+                               ConnectionId: data.Options.ConnectionId,
+                               ProjectSlug:  data.Options.ProjectSlug,
+                       },
+               },
+               Extract: func(userRes *models.CircleciWorkflow, row 
*api.RawData) ([]any, errors.Error) {
+                       toolL := *userRes
                        toolL.ConnectionId = data.Options.ConnectionId
                        toolL.ProjectSlug = data.Options.ProjectSlug
                        if userRes.CreatedDate != nil && userRes.StoppedAt != 
nil {
@@ -57,7 +57,7 @@ func ExtractWorkflows(taskCtx plugin.SubTaskContext) 
errors.Error {
                                        toolL.DurationSec = durationSec
                                }
                        }
-                       return []interface{}{
+                       return []any{
                                &toolL,
                        }, nil
                },

Reply via email to