michaelgriskified opened a new issue, #9167:
URL: https://github.com/apache/devlake/issues/9167

   
   ### Description
   
   `api_collector_stateful.go`'s `ResponseParser` wrapper discards the records 
returned by the
   plugin's own parser whenever that parser signals `ErrFinishCollect`. The 
result is a **silent,
   page-aligned data loss** at the `timeAfter` boundary: the last page of a 
time-bounded collection
   is dropped in full, including the records on it that are inside the 
requested window.
   
   Nothing errors. The task reports `TASK_COMPLETED` with zero failed subtasks, 
and every
   internal consistency check passes, because the tool tables and the domain 
tables agree with
   each other — they are both derived from the same truncated raw data. The 
loss is only visible
   by comparing against the upstream API directly.
   
   ### Root cause
   
   `backend/helpers/pluginhelper/api/api_collector_stateful.go`:
   
   ```go
   ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) {
       items, err := args.CollectNewRecordsByList.ResponseParser(res)
       if err != nil {
           return nil, err          // <-- discards `items` even when err is 
ErrFinishCollect
       }
   ```
   
   Plugin parsers are written to return the valid prefix of a page *together 
with* the stop signal.
   For example `backend/plugins/circleci/tasks/pipeline_collector.go`:
   
   ```go
   for _, item := range data.Items {
       pipelineCreatedAt, err := extractCreatedAt(item)
       if err != nil { return nil, err }
       if pipelineCreatedAt.Before(*timeAfter) {
           return filteredItems, api.ErrFinishCollect   // in-window items + 
stop
       }
       filteredItems = append(filteredItems, item)
   }
   ```
   
   The wrapper replaces `filteredItems` with `nil`. `ApiCollector.fetchAsync` 
then takes the
   `count == 0` early return and never reaches `db.Create`, so the page is 
never persisted:
   
   ```go
   items, err := collector.args.ResponseParser(res)
   if err != nil {
       if errors.Is(err, ErrFinishCollect) {
           logger.Info("a fetch stop by parser, reqInput: #%s", reqData.Params)
           handler = nil
       } ...
   }
   count := len(items)
   if count == 0 {
       collector.args.Ctx.IncProgress(1)
       return nil          // <-- page silently dropped
   }
   ```
   
   Note `fetchAsync` itself handles this correctly — it persists whatever it is 
given before
   stopping pagination. The loss is introduced purely by the wrapper nulling 
the slice.
   
   This affects any plugin using `NewStatefulApiCollectorForFinalizableEntity` 
whose
   `ResponseParser` returns a partial page with `ErrFinishCollect`, not only 
circleci.
   
   ### Regression lineage
   
   The circleci parser gained its `return filteredItems, api.ErrFinishCollect` 
line in #7820
   (merged 2024-08-15), which fixed #7797 "CircleCI pipelines collected from 
before time range".
   
   That plugin-side change is correct on its own terms — it stops collection at 
the window
   boundary instead of running past it. But because the stateful wrapper nulls 
the slice whenever
   the parser returns an error, the fix effectively traded *collecting too 
much* for *silently
   collecting too little*. #7797's symptom was visible in the data; this one is 
not.
   
   ### Impact
   
   Loss is bounded by one page, so it is **proportionally worst for low-volume 
scopes** — the
   opposite of where it is likely to be noticed. Measured on three repositories 
with
   `timeAfter` = 90 days, `PageSize` = 20 (the CircleCI default):
   
   | repository | upstream API | collected | lost | loss |
   |---|---|---|---|---|
   | A (0.38 pipelines/day) | 34 | 20 | 14 | **41%** |
   | B (3.1 pipelines/day) | 276 | 260 | 16 | 5.8% |
   | C (6.3 pipelines/day) | 568 | 560* | 8 | 1.4% |
   
   \* predicted from the same model; C was collected after the workaround was 
applied and
   returned the full 568.
   
   ### Reproduction
   
   1. Configure a CircleCI connection and a project scope for a repository with 
more pipelines in
      the window than one page (>20), where the window boundary falls mid-page.
   2. Set a blueprint `timeAfter` such that the boundary page contains both 
in-window and
      out-of-window pipelines.
   3. Run the blueprint. It completes `TASK_COMPLETED`, zero failed subtasks.
   4. `SELECT count(*) FROM _tool_circleci_pipelines WHERE project_slug = 
'...'` returns an exact
      multiple of the page size.
   5. Compare with `GET /v2/project/{slug}/pipeline` counted directly over the 
same window — the
      difference is the in-window records on the discarded page.
   
   Confirming signals, all consistent:
   
   - `_raw_circleci_api_pipelines` holds the same truncated count, so the loss 
is at collection,
     not extraction.
   - Collected pipeline `number` values are perfectly contiguous — the tail is 
amputated, records
     are not dropped at random.
   - A `fullSync: true` re-run reproduces the identical count.
   - **In-band detector:** `collectPipelines.finishedRecords` counts pages 
fetched while
     `extractPipelines.finishedRecords` counts records kept; `pages - 
kept/PageSize` is the number
     of discarded pages. This agreed with the `a fetch stop by parser` log line 
on exactly the runs
     that lost data.
   
   ### Suggested fix
   
   Preserve the partial page when the stop signal is `ErrFinishCollect`:
   
   ```go
   items, err := args.CollectNewRecordsByList.ResponseParser(res)
   if err != nil {
       if errors.Is(err, ErrFinishCollect) {
           return items, err
       }
       return nil, err
   }
   ```
   
   `fetchAsync` already persists the returned items and then stops paginating, 
so no other change
   is needed.
   
   ### Workaround for operators
   
   Set `timeAfter` earlier than the window you actually need, so the discarded 
page falls in the
   buffer rather than in the data you rely on. The buffer must exceed **one 
page expressed in
   time**, which varies with each scope's rate: at `PageSize` 20 that is ~3 
days for a repository
   at 6.3 pipelines/day but ~53 days for one at 0.38/day. A fixed buffer chosen 
for busy scopes
   will not protect quiet ones.
   
   ### Environment
   
   - DevLake `v1.0.3-beta17` (`8fe26f4`), Helm chart `1.0.3-beta9`
   - PostgreSQL backend
   - Plugins: circleci, github_graphql
   - `PIPELINE_MAX_PARALLEL=1`, `API_REQUESTS_PER_HOUR=3000`
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to