blob: 51e7565f0639d9dedc6ab41f4782dd13393c2fa0 [file]
/*
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 (
"time"
"github.com/apache/devlake/core/dal"
"github.com/apache/devlake/core/errors"
"github.com/apache/devlake/core/plugin"
helper "github.com/apache/devlake/helpers/pluginhelper/api"
"github.com/apache/devlake/plugins/kiro/models"
)
// parseFunc turns one downloaded object into batches of rows ready for
// insertion. It is a pure function so it can run on a worker goroutine without
// touching the database.
//
// The result is a slice of batches rather than one flat slice because GORM
// derives the target table from the element type: a mixed []interface{} fails
// with "Table not set". A report CSV yields two different models, so each model
// gets its own homogeneous batch.
type parseFunc func(data []byte, connectionId uint64, scopeId string) ([]rowBatch, errors.Error)
// rowBatch is a set of rows that all belong to the same table.
type rowBatch struct {
// rows must be a typed slice (e.g. []*models.KiroChatLog), not
// []interface{}, so GORM can resolve the table.
rows interface{}
count int
}
// parsedFile is a worker's output, handed to the main goroutine for persistence.
type parsedFile struct {
meta *models.KiroS3FileMeta
batches []rowBatch
err errors.Error
}
// rowCount totals the rows across every batch, for the cursor's record count.
func (p parsedFile) rowCount() int {
total := 0
for _, batch := range p.batches {
total += batch.count
}
return total
}
// pendingFiles returns the files still awaiting extraction for a scope.
//
// Files that have already failed MaxAttempts times are excluded. Without that
// bound, one permanently malformed object is retried on every run: the log fills
// with the same error and the scope never reaches a finished state.
func pendingFiles(db dal.Dal, connectionId uint64, scopeId string, fileType string) ([]models.KiroS3FileMeta, errors.Error) {
var files []models.KiroS3FileMeta
err := db.All(&files,
dal.From(&models.KiroS3FileMeta{}),
dal.Where("connection_id = ? AND scope_id = ? AND file_type = ? AND processed = ? AND attempt_count < ?",
connectionId, scopeId, fileType, false, models.MaxAttempts),
)
if err != nil {
return nil, errors.Default.Wrap(err, "failed to query pending kiro files")
}
return files, nil
}
// extractFiles downloads and parses every pending file of one type, then stores
// the results.
//
// Downloads run concurrently but all database writes happen on the calling
// goroutine. That split is a correctness requirement, not an optimization:
// twenty workers inserting into the same table concurrently deadlock on MySQL
// gap locks, and DevLake's retry layer would turn those deadlocks into
// intermittent failures that only appear under load.
func extractFiles(taskCtx plugin.SubTaskContext, fileType string, parse parseFunc) errors.Error {
data := taskCtx.GetData().(*KiroTaskData)
db := taskCtx.GetDal()
logger := taskCtx.GetLogger()
files, err := pendingFiles(db, data.Options.ConnectionId, data.Options.ScopeId, fileType)
if err != nil {
return err
}
if len(files) == 0 {
logger.Info("no pending %s files for scope %s", fileType, data.Options.ScopeId)
return nil
}
logger.Info("extracting %d %s files", len(files), fileType)
taskCtx.SetProgress(0, len(files))
client := data.S3Clients.ForFileType(fileType)
results := make(chan parsedFile, len(files))
scheduler, err := helper.NewWorkerScheduler(
taskCtx.GetContext(),
data.WorkerCount(),
// The tick is a global rate limiter, not a per-worker one: every task
// waits for one tick before running, so this value caps total
// throughput regardless of pool size. A one-second tick would pin the
// whole run to one file per second and leave the pool idle - measured
// at 13 seconds for 13 files. Keep it well below the per-object fetch
// time so the workers, not the ticker, set the pace.
time.Millisecond,
logger,
)
if err != nil {
return err
}
defer scheduler.Release()
for i := range files {
meta := files[i]
scheduler.SubmitBlocking(func() errors.Error {
// Workers only fetch and parse. Anything touching the database
// happens after WaitAsync below.
body, getErr := client.GetObjectBytes(meta.S3Path)
if getErr != nil {
results <- parsedFile{meta: &meta, err: getErr}
return nil
}
batches, parseErr := parse(body, data.Options.ConnectionId, data.Options.ScopeId)
results <- parsedFile{meta: &meta, batches: batches, err: parseErr}
return nil
})
}
if waitErr := scheduler.WaitAsync(); waitErr != nil {
return waitErr
}
close(results)
for result := range results {
if saveErr := persistFile(db, result); saveErr != nil {
return saveErr
}
taskCtx.IncProgress(1)
}
return nil
}
// persistFile stores one file's rows and updates its cursor entry.
//
// A parse or download failure is recorded rather than aborting the run: one bad
// object should not block the rest of the scope. The reason is written to
// error_message so a short month can be explained with a query instead of
// guesswork.
func persistFile(db dal.Dal, result parsedFile) errors.Error {
meta := result.meta
if result.err != nil {
meta.AttemptCount++
meta.ErrorMessage = result.err.Error()
// Processed stays false so the file is retried, up to MaxAttempts.
if err := db.Update(meta); err != nil {
return errors.Default.Wrap(err, "failed to record kiro extraction failure")
}
return nil
}
// Each batch is written separately so GORM sees a typed slice and can
// resolve the target table.
for _, batch := range result.batches {
if batch.count == 0 {
continue
}
if err := db.CreateOrUpdate(batch.rows); err != nil {
// A write failure counts as an attempt too, otherwise a row that
// cannot be stored would be fetched forever.
meta.AttemptCount++
meta.ErrorMessage = err.Error()
if updateErr := db.Update(meta); updateErr != nil {
return errors.Default.Wrap(updateErr, "failed to record kiro write failure")
}
return nil
}
}
now := time.Now()
meta.Processed = true
meta.ProcessedTime = &now
meta.RecordCount = result.rowCount()
meta.ErrorMessage = ""
if err := db.Update(meta); err != nil {
return errors.Default.Wrap(err, "failed to mark kiro file processed")
}
return nil
}