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 5eee167f3 fix(customize): skip rows with a missing raw record instead 
of ending the scan (#9027)
5eee167f3 is described below

commit 5eee167f3556145ac9199367098e3aafa31698af
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Mon Aug 3 02:54:58 2026 -0400

    fix(customize): skip rows with a missing raw record instead of ending the 
scan (#9027)
    
    The extractor cursor LEFT JOINs the raw table, so a domain row whose
    _raw_data_id points at a raw record that has since been deleted comes back 
with
    a NULL data column. That value fell through to the default branch of the 
type
    switch, which returned nil and ended the whole scan.
    
    Nothing surfaced: the subtask reported success, and every row ordered after 
the
    first orphan silently kept whatever custom field values it already had. The
    reporter saw 1,382 orphaned rows out of 30,287 leave roughly 28,000 rows
    unpopulated.
    
    Skip the row and carry on. extractCustomizedFields now returns how many 
rows it
    skipped for that reason, and the caller logs a warning naming the table and 
the
    count, so the condition is visible instead of silent.
    
    Also return rows.Err() at the end of the scan. An error raised while 
iterating
    the cursor was previously discarded, which is the same class of silent 
partial
    processing.
    
    Signed-off-by: Bhanu Chander Vallabaneni <[email protected]>
---
 .../customize/tasks/customized_fields_extractor.go |  40 ++++--
 .../tasks/customized_fields_extractor_test.go      | 150 +++++++++++++++++++++
 2 files changed, 177 insertions(+), 13 deletions(-)

diff --git a/backend/plugins/customize/tasks/customized_fields_extractor.go 
b/backend/plugins/customize/tasks/customized_fields_extractor.go
index 7bd1d932d..9a8454c66 100644
--- a/backend/plugins/customize/tasks/customized_fields_extractor.go
+++ b/backend/plugins/customize/tasks/customized_fields_extractor.go
@@ -46,20 +46,28 @@ func ExtractCustomizedFields(taskCtx plugin.SubTaskContext) 
errors.Error {
                return nil
        }
        d := taskCtx.GetDal()
-       var err error
+       logger := taskCtx.GetLogger()
        for _, rule := range data.Options.TransformationRules {
-               err = extractCustomizedFields(taskCtx.GetContext(), d, 
rule.Table, rule.RawDataTable, rule.RawDataParams, rule.Mapping)
+               orphaned, err := extractCustomizedFields(taskCtx.GetContext(), 
d, rule.Table, rule.RawDataTable, rule.RawDataParams, rule.Mapping)
                if err != nil {
                        return errors.Default.Wrap(err, "error extracting 
customized fields")
                }
+               if orphaned > 0 {
+                       logger.Warn(nil,
+                               "skipped %d row(s) of table %s: the raw record 
referenced by _raw_data_id no longer exists in %s",
+                               orphaned, rule.Table, rule.RawDataTable)
+               }
        }
        return nil
 }
 
-func extractCustomizedFields(ctx context.Context, d dal.Dal, table, rawTable, 
rawDataParams string, extractor map[string]string) error {
+// extractCustomizedFields walks the domain layer table and copies configured 
JSON paths out of the
+// raw record behind each row. It returns the number of rows skipped because 
no raw record backed
+// them, so the caller can report that rather than leaving it invisible.
+func extractCustomizedFields(ctx context.Context, d dal.Dal, table, rawTable, 
rawDataParams string, extractor map[string]string) (int, error) {
        pkFields, err := dal.GetPrimarykeyColumns(d, &models.Table{Name: table})
        if err != nil {
-               return err
+               return 0, err
        }
        rawDataField := fmt.Sprintf("%s.data", rawTable)
        // `fields` only include `_raw_data_id` and primary keys coming from 
the domain layer table, and `data` coming from the raw layer
@@ -76,21 +84,22 @@ func extractCustomizedFields(ctx context.Context, d 
dal.Dal, table, rawTable, ra
        }
        rows, err := d.Cursor(clauses...)
        if err != nil {
-               return err
+               return 0, err
        }
        defer rows.Close()
 
+       orphaned := 0
        for rows.Next() {
                select {
                case <-ctx.Done():
-                       return ctx.Err()
+                       return orphaned, ctx.Err()
                default:
                }
                row := make(map[string]interface{})
                updates := make(map[string]interface{})
                err = d.Fetch(rows, &row)
                if err != nil {
-                       return err
+                       return orphaned, err
                }
                switch blob := row["data"].(type) {
                case []byte:
@@ -104,7 +113,7 @@ func extractCustomizedFields(ctx context.Context, d 
dal.Dal, table, rawTable, ra
                                // special case for issues custom_fields
                                rawDataId, ok := row["_raw_data_id"].(int64)
                                if !ok {
-                                       return errors.Default.New("_raw_data_id 
is not int64")
+                                       return orphaned, 
errors.Default.New("_raw_data_id is not int64")
                                }
                                if table == "issues" && result.IsArray() {
                                        issueId := row["id"].(string)
@@ -115,7 +124,7 @@ func extractCustomizedFields(ctx context.Context, d 
dal.Dal, table, rawTable, ra
                                                dal.Where("issue_id = ? AND 
field_id = ?", issueId, fieldId),
                                        )
                                        if err != nil {
-                                               return err
+                                               return orphaned, err
                                        }
 
                                        result.ForEach(func(_, v gjson.Result) 
bool {
@@ -142,7 +151,12 @@ func extractCustomizedFields(ctx context.Context, d 
dal.Dal, table, rawTable, ra
                                }
                        }
                default:
-                       return nil
+                       // The cursor LEFT JOINs the raw table, so a domain row 
whose _raw_data_id points at a
+                       // raw record that has since been deleted comes back 
with a NULL data column. There is
+                       // nothing to extract from it, but the rows after it 
are usually fine, so skip this one
+                       // instead of ending the scan.
+                       orphaned++
+                       continue
                }
 
                if len(updates) > 0 {
@@ -151,15 +165,15 @@ func extractCustomizedFields(ctx context.Context, d 
dal.Dal, table, rawTable, ra
                        delete(row, "data")
                        query, params, err := mkUpdate(table, updates, row)
                        if err != nil {
-                               return err
+                               return orphaned, err
                        }
                        err = d.Exec(query, params...)
                        if err != nil {
-                               return errors.Default.Wrap(err, "Exec SQL 
error")
+                               return orphaned, errors.Default.Wrap(err, "Exec 
SQL error")
                        }
                }
        }
-       return nil
+       return orphaned, rows.Err()
 }
 
 // fillInUpdates fills in the updates map with the result of the gjson query
diff --git 
a/backend/plugins/customize/tasks/customized_fields_extractor_test.go 
b/backend/plugins/customize/tasks/customized_fields_extractor_test.go
new file mode 100644
index 000000000..9fc6ff894
--- /dev/null
+++ b/backend/plugins/customize/tasks/customized_fields_extractor_test.go
@@ -0,0 +1,150 @@
+/*
+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 tasks
+
+import (
+       "context"
+       "testing"
+
+       "github.com/apache/incubator-devlake/core/dal"
+       mockdal "github.com/apache/incubator-devlake/mocks/core/dal"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/mock"
+)
+
+// fetchedRow is one row the mocked cursor hands back, in the shape d.Fetch 
produces.
+type fetchedRow map[string]interface{}
+
+// newExtractorMocks wires a Dal whose cursor yields the given rows in order. 
It returns the Dal
+// mock plus a pointer to a slice collecting every UPDATE statement executed, 
so a test can tell
+// which rows actually made it through.
+func newExtractorMocks(t *testing.T, rows []fetchedRow) (*mockdal.Dal, 
*[]string) {
+       t.Helper()
+
+       idColumn := new(mockdal.ColumnMeta)
+       idColumn.On("Name").Return("id").Maybe()
+       idColumn.On("PrimaryKey").Return(true, true).Maybe()
+
+       cursor := new(mockdal.Rows)
+       // Next reports true once per row, then false to end the scan.
+       call := 0
+       cursor.On("Next").Return(func() bool {
+               ok := call < len(rows)
+               call++
+               return ok
+       }).Maybe()
+       cursor.On("Close").Return(nil).Maybe()
+       cursor.On("Err").Return(nil).Maybe()
+
+       d := new(mockdal.Dal)
+       d.On("GetColumns", mock.Anything, mock.Anything).
+               Return([]dal.ColumnMeta{idColumn}, nil).Maybe()
+       d.On("Cursor", mock.Anything).Return(cursor, nil).Maybe()
+
+       // Fetch copies the row for the current cursor position into the 
destination map.
+       fetched := 0
+       d.On("Fetch", mock.Anything, mock.Anything).Run(func(args 
mock.Arguments) {
+               dst, ok := args.Get(1).(*map[string]interface{})
+               if !ok {
+                       t.Fatalf("Fetch called with %T, want 
*map[string]interface{}", args.Get(1))
+               }
+               for k, v := range rows[fetched] {
+                       (*dst)[k] = v
+               }
+               fetched++
+       }).Return(nil).Maybe()
+
+       executed := make([]string, 0)
+       d.On("Exec", mock.Anything, mock.Anything).Run(func(args 
mock.Arguments) {
+               executed = append(executed, args.Get(0).(string))
+       }).Return(nil).Maybe()
+
+       return d, &executed
+}
+
+func Test_extractCustomizedFields_skipsOrphanedRowsAndKeepsScanning(t 
*testing.T) {
+       // The cursor LEFT JOINs the raw table, so a domain row whose raw 
record has been deleted comes
+       // back with a NULL data column. Before the fix that row ended the 
whole scan, leaving every
+       // later row unpopulated while the subtask still reported success.
+       rows := []fetchedRow{
+               {"_raw_data_id": int64(1), "id": "ISSUE-1", "data": nil},
+               {"_raw_data_id": int64(2), "id": "ISSUE-2", "data": 
`{"foo":"bar"}`},
+               {"_raw_data_id": int64(3), "id": "ISSUE-3", "data": nil},
+               {"_raw_data_id": int64(4), "id": "ISSUE-4", "data": 
`{"foo":"baz"}`},
+       }
+       d, executed := newExtractorMocks(t, rows)
+
+       orphaned, err := extractCustomizedFields(
+               context.Background(), d, "issues", "_raw_jira_api_issues", 
"params",
+               map[string]string{"x_custom": "foo"})
+
+       assert.NoError(t, err)
+       assert.Equal(t, 2, orphaned, "both rows without a raw record should be 
counted")
+       assert.Len(t, *executed, 2, "the two rows that do have raw data should 
still be updated")
+}
+
+func Test_extractCustomizedFields_allRowsOrphanedIsNotAnError(t *testing.T) {
+       // A rule whose raw records have all been cleaned up is not a failure; 
it just has nothing to
+       // extract. The caller reports the count so it does not pass unnoticed.
+       rows := []fetchedRow{
+               {"_raw_data_id": int64(1), "id": "ISSUE-1", "data": nil},
+               {"_raw_data_id": int64(2), "id": "ISSUE-2", "data": nil},
+       }
+       d, executed := newExtractorMocks(t, rows)
+
+       orphaned, err := extractCustomizedFields(
+               context.Background(), d, "issues", "_raw_jira_api_issues", 
"params",
+               map[string]string{"x_custom": "foo"})
+
+       assert.NoError(t, err)
+       assert.Equal(t, 2, orphaned)
+       assert.Empty(t, *executed)
+}
+
+func Test_extractCustomizedFields_noOrphansReportsZero(t *testing.T) {
+       rows := []fetchedRow{
+               {"_raw_data_id": int64(1), "id": "ISSUE-1", "data": 
`{"foo":"bar"}`},
+               {"_raw_data_id": int64(2), "id": "ISSUE-2", "data": 
`{"foo":"baz"}`},
+       }
+       d, executed := newExtractorMocks(t, rows)
+
+       orphaned, err := extractCustomizedFields(
+               context.Background(), d, "issues", "_raw_jira_api_issues", 
"params",
+               map[string]string{"x_custom": "foo"})
+
+       assert.NoError(t, err)
+       assert.Zero(t, orphaned)
+       assert.Len(t, *executed, 2)
+}
+
+func Test_extractCustomizedFields_honoursCancelledContext(t *testing.T) {
+       rows := []fetchedRow{
+               {"_raw_data_id": int64(1), "id": "ISSUE-1", "data": 
`{"foo":"bar"}`},
+       }
+       d, executed := newExtractorMocks(t, rows)
+
+       ctx, cancel := context.WithCancel(context.Background())
+       cancel()
+
+       _, err := extractCustomizedFields(
+               ctx, d, "issues", "_raw_jira_api_issues", "params",
+               map[string]string{"x_custom": "foo"})
+
+       assert.ErrorIs(t, err, context.Canceled)
+       assert.Empty(t, *executed)
+}

Reply via email to