Skip to content

Fix child pipeline lookup - #1737

Open
atomicgamedeveloper wants to merge 9 commits into
INTO-CPS-Association:feature/distributed-demofrom
atomicgamedeveloper:pipeline-child-id-fix
Open

Fix child pipeline lookup#1737
atomicgamedeveloper wants to merge 9 commits into
INTO-CPS-Association:feature/distributed-demofrom
atomicgamedeveloper:pipeline-child-id-fix

Conversation

@atomicgamedeveloper

@atomicgamedeveloper atomicgamedeveloper commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Pull Request Template

Title

Fix child pipeline lookup

Type of Change

  • New feature
  • Bug fix
  • Documentation update
  • Refactoring
  • Security patch
  • UI/UX improvement

Description

Fixes #1705.

Use GitLab bridge jobs to resolve the actual child pipeline instead of assuming it is the next pipeline ID.

The previously sequential E2E pipeline tests now run with three workers. Read-only GitLab loading requests also retry temporary failures safely.

Testing

Added unit and integration coverage for child pipeline lookup and retrying.

For a failed read-only GitLab request, the app waits 250 ms and retries up to two times. Requests that start a pipeline are never retried, so this cannot create duplicate jobs.

Impact

Status checks, logs, and cancellation use the correct child pipeline when other pipelines run at the same time. E2E pipeline tests run in parallel while handling temporary GitLab loading failures.

Additional Information

Checklist

  • My code adheres to the coding and style guidelines of the project.
  • I have added tests for all the new code and any changes made to
    existing code.
  • I have made corresponding changes to the documentation.

…d-status handling in measurement.pipeline.ts, measurement.runner.ts, and ExecutionStatusService.ts respectively
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.80851% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.92%. Comparing base (cdad2f8) to head (9076701).

Files with missing lines Patch % Lines
...del/backend/gitlab/measure/measurement.pipeline.ts 95.58% 3 Missing ⚠️
...e/digitaltwins/execution/executionStatusManager.ts 89.47% 2 Missing ⚠️
...model/backend/util/digitalTwinPipelineExecution.ts 88.88% 1 Missing ⚠️
Additional details and impacted files
@@                     Coverage Diff                      @@
##           feature/distributed-demo    #1737      +/-   ##
============================================================
- Coverage                     98.37%   96.92%   -1.45%     
============================================================
  Files                            33      185     +152     
  Lines                          2155     6152    +3997     
  Branches                          0     1062    +1062     
============================================================
+ Hits                           2120     5963    +3843     
- Misses                           35      186     +151     
- Partials                          0        3       +3     
Files with missing lines Coverage Δ
client/src/model/backend/gitlab/backend.ts 100.00% <100.00%> (ø)
...src/model/backend/gitlab/execution/pipelineCore.ts 100.00% <ø> (ø)
client/src/model/backend/gitlab/instance.ts 100.00% <100.00%> (ø)
...ient/src/model/backend/gitlab/measure/constants.ts 100.00% <100.00%> (ø)
...model/backend/gitlab/measure/measurement.runner.ts 95.62% <100.00%> (ø)
.../src/model/backend/state/ExecutionStatusService.ts 100.00% <100.00%> (ø)
client/src/model/backend/util/init.ts 96.49% <100.00%> (ø)
client/src/model/backend/util/requestRetry.ts 100.00% <100.00%> (ø)
...model/backend/util/digitalTwinPipelineExecution.ts 90.16% <88.88%> (ø)
...e/digitaltwins/execution/executionStatusManager.ts 97.64% <89.47%> (ø)
... and 1 more

... and 207 files with indirect coverage changes

Components Coverage Δ
Website 96.92% <96.80%> (∅)
Lib Microservice ∅ <ø> (∅)
Logger Microservice ∅ <ø> (∅)
Execution Runner ∅ <ø> (∅)
DTaaS CLI ∅ <ø> (∅)
DTaaS Services CLI ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes child pipeline resolution across the client by looking up downstream (child) pipelines via GitLab bridge jobs instead of assuming parentPipelineId + 1, and adjusts polling/cancellation and test coverage accordingly. It also introduces a small retry utility for read-only GitLab-loading flows and updates Playwright E2E tests/config to run with increased parallelism.

Changes:

  • Add getPipelineBridges + getChildPipelineId to the backend interface and implement bridge-based child pipeline lookup for GitLab.
  • Update execution status polling, cancellation flows, and measurement pipeline lifecycle to use resolved child pipeline IDs (including retries while the child pipeline is not yet created).
  • Add/adjust unit, integration, and E2E test coverage; introduce a shared E2E helper and update Playwright worker setup.

Reviewed changes

Copilot reviewed 35 out of 35 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
client/test/unit/route/digitaltwins/execution/executionStatusManager/testSetup.ts Adds a reusable spy helper for getChildPipelineId in unit tests.
client/test/unit/route/digitaltwins/execution/executionStatusManager/childPipeline.test.ts Updates unit tests to validate real child pipeline lookup (and retry when not yet available).
client/test/unit/model/backend/requestRetry.test.ts Adds unit coverage for the new retry helper.
client/test/unit/model/backend/gitlab/model/ExecutionStatusService.test.ts Updates backend mock expectations and adds cases for missing/errored child pipeline lookup.
client/test/unit/model/backend/gitlab/measure/measurement.testUtil.ts Extends measurement backend mocks and active pipeline shape to include childPipelineId.
client/test/unit/model/backend/gitlab/measure/measurement.runner.test.ts Tests that unload cancellation cancels discovered child pipelines and avoids guessing.
client/test/unit/model/backend/gitlab/measure/measurement.pipeline.test.ts Updates pipeline lifecycle tests for child lookup + retry and new retry-on-rejection behavior.
client/test/unit/model/backend/gitlab/measure/measurement.lifecycle.test.ts Updates mocks to remove the old getChildPipelineId helper from pipelineCore.
client/test/unit/model/backend/gitlab/measure/measurement.envSetup.ts Updates environment setup mocks to remove the old getChildPipelineId helper.
client/test/unit/model/backend/gitlab/execution/pipelineCore.test.ts Removes tests for the deleted getChildPipelineId(parent+1) behavior.
client/test/unit/model/backend/gitlab.test.ts Adds unit tests for GitlabInstance.getChildPipelineId() bridge-based lookup.
client/test/unit/model/backend/digitalTwinUtil.test.ts Extends API mock surface to include pipeline bridges.
client/test/unit/model/backend/digitalTwin/testSetup.ts Extends GitLab instance mocks to include getChildPipelineId.
client/test/unit/model/backend/digitalTwin/stop.test.ts Ensures stopping a child pipeline uses resolved child ID and handles “not yet created”.
client/test/unit/model/backend/backend.test.ts Adds unit test for GitlabAPI.getPipelineBridges mapping behavior.
client/test/integration/route/digitaltwins/execution/executionStatusManager/childPipeline.test.tsx Updates integration test to spy on getChildPipelineId during child polling.
client/test/e2e/tests/Measurement.test.ts Increases timeouts for a measurement E2E test to match longer pipeline variability.
client/test/e2e/tests/execution.helpers.ts Adds shared polling helper for execution-count-based waits in E2E tests.
client/test/e2e/tests/DigitalTwins.test.ts Switches to the shared execution-count wait helper for “start then history” flow.
client/test/e2e/tests/ConcurrentExecution.test.ts Updates concurrent-start flow to wait for persisted history entries via execution count.
client/test/mocks/mockBackendData.ts Extends mocks for new backend API surface and child lookup.
client/src/route/digitaltwins/execution/executionStatusManager.ts Resolves child pipeline IDs via backend bridge lookup and retries until available.
client/src/model/backend/util/requestRetry.ts Introduces a generic retry helper with default attempts/delay.
client/src/model/backend/util/init.ts Refactors instance initialization + wraps read-only loading flows with retries.
client/src/model/backend/util/digitalTwinPipelineExecution.ts Updates stop pipeline resolution to use backend child pipeline lookup.
client/src/model/backend/state/ExecutionStatusService.ts Updates execution status refresh to resolve child pipelines via backend lookup.
client/src/model/backend/interfaces/backendInterfaces.ts Adds bridge and child-pipeline lookup APIs/types to backend interfaces.
client/src/model/backend/gitlab/measure/measurement.types.ts Adds optional childPipelineId to active pipeline tracking.
client/src/model/backend/gitlab/measure/measurement.runner.ts Cancels only known child pipelines during unload; removes guessed ID logic.
client/src/model/backend/gitlab/measure/measurement.pipeline.ts Adds retrying child ID resolution and safer cancellation; adds backend init retry.
client/src/model/backend/gitlab/measure/constants.ts Adds pipeline acceptance delay constant.
client/src/model/backend/gitlab/instance.ts Implements getChildPipelineId via getPipelineBridges.
client/src/model/backend/gitlab/execution/pipelineCore.ts Removes the old parent+1 child pipeline helper.
client/src/model/backend/gitlab/backend.ts Implements getPipelineBridges using GitBeaker’s pipeline bridge jobs.
client/playwright.config.ts Sets global workers to 3 and adjusts project dependencies for parallel E2E.

Comment on lines +255 to +275
if (pipelineId == null) {
if (hasTimedOut(startTime)) {
await handleTimeout(
digitalTwin.DTName,
setButtonText,
setLogButtonDisabled,
dispatch,
executionId,
);
return;
}
await delay(PIPELINE_POLL_INTERVAL);
await checkChildPipelineStatus({
setButtonText,
digitalTwin,
setLogButtonDisabled,
dispatch,
startTime,
executionId,
});
return;
Comment on lines +92 to +96
for (;;) {
const childPipelineId = await backend.getChildPipelineId(
projectId,
parentPipelineId,
);
Comment on lines +58 to +62
await backend.api.cancelPipeline(projectId, pipelineId);
const childPipelineId = await backend
.getChildPipelineId(projectId, pipelineId)
.catch(() => null);
await cancelChildPipeline(backend, projectId, childPipelineId);
await page.waitForTimeout(DEBOUNCE_TIME); // NOSONAR
await startButton.click();
await waitForNewExecution(historyButton, previousCount);
await waitForExecutionCount(historyButton, 1);
Comment on lines +55 to +60
await waitForExecutionCount(historyButton, 1);
await expect(startButton).toBeEnabled({
timeout: EXECUTION_START_TIMEOUT,
});
await startButton.click();
await waitForExecutionCount(historyButton, 2);
@prasadtalasila

Copy link
Copy Markdown
Contributor

@atomicgamedeveloper please see the review from claude

Full Code Review — PR #1737 "Fix child pipeline lookup"

INTO-CPS-Association/DTaaS — atomicgamedeveloper:pipeline-child-id-fix → feature/distributed-demo

Summary

Replaces the previous getChildPipelineId(parentId) = parentId + 1 heuristic
with a real lookup through GitLab's pipeline bridges API
(Jobs.allPipelineBridgesdownstream_pipeline.id). Also adds:

  • retry-on-failure for read-only GitLab calls (retryRequest, used in
    initializeBackend)
  • a bounded polling loop to resolve the child pipeline ID
    (resolveChildPipelineId)
  • a "pipeline rejected → cancel and restart" recovery path
    (retryRejectedPipeline)
  • parallelized E2E Playwright config (previously-sequential tests now run
    with dedicated worker capacity instead of workers: 1)

Files reviewed

Confirmed from diff: backend.ts, instance.ts, pipelineCore.ts,
measure/constants.ts, measure/measurement.pipeline.ts,
playwright.config.ts.
Not retrievable in this session (GitHub's diff view truncated before
reaching them, and direct raw/API fetch was blocked by tool restrictions):
requestRetry.ts, measurement.runner.ts, ExecutionStatusService.ts,
executionStatusManager.ts, digitalTwinPipelineExecution.ts,
init.ts, backendInterfaces.ts, all test files. Findings below are
scoped to what was actually visible; items depending on the unseen files
are marked as open questions, not confirmed defects.

Correctness / design

  1. Core fix is sound. parentId + 1 was never a safe way to identify a
    child pipeline once pipelines can be created concurrently — the "+1"
    pipeline could belong to a different, unrelated run. Using GitLab's
    bridge job data (downstream_pipeline.id) asks GitLab for ground truth
    instead of guessing, closing what was effectively a cross-pipeline
    status/cancel/log confusion bug (issue [BUG] Pipeline execution assumes child pipeline ID is always parent ID + 1 #1705).

  2. resolveChildPipelineId is a correctly-bounded polling loop.

    for (;;) {
      const childPipelineId = await backend.getChildPipelineId(projectId, parentPipelineId);
      if (childPipelineId != null) return childPipelineId;
      ensurePipelineCanContinue(parentPipelineId, startTime);
      await delay(PIPELINE_POLL_INTERVAL);
    }

    ensurePipelineCanContinue enforces both a user-abort check and a
    hasTimedOut(startTime, MAX_EXECUTION_TIME) timeout, so the loop cannot
    run forever under normal conditions. Two follow-ups:

    • If backend.getChildPipelineId throws (e.g. transient network error)
      rather than resolving to null, the throw is not visibly caught in
      this loop, so a single transient failure could abort the whole
      execution rather than being retried — inconsistent with the "retry
      read-only GitLab requests" goal stated in the PR description, since
      initializeBackend gets that treatment via retryRequest but this
      hot polling path apparently does not (from what's visible).
    • Every iteration issues a fresh bridges API call. For long
      MAX_EXECUTION_TIME values and multiple concurrent pipelines (this
      PR is explicitly enabling more parallelism), this raises GitLab API
      call volume noticeably. Worth confirming PIPELINE_POLL_INTERVAL
      is generous enough to stay well under GitLab's rate limits.
  3. cancelPipelineAndChild / cancelChildPipeline swallow errors
    silently.

    async function cancelPipelineAndChild(backend, pipelineId) {
      try {
        ...
        const childPipelineId = await backend
          .getChildPipelineId(projectId, pipelineId)
          .catch(() => null);
        await cancelChildPipeline(backend, projectId, childPipelineId);
      } catch {
        // Continue with the remaining pipelines.
      }
    }

    Best-effort cleanup across multiple pipelines is a reasonable pattern —
    one failed cancel shouldn't block the others. But the bare catch {}
    with no logging means a failed cancel is invisible: a pipeline could
    keep consuming runner/compute resources with no operator-visible trace
    of why. A debug/warn-level log on the caught error would preserve the
    fail-open behavior while making the failure diagnosable.

  4. retryRejectedPipeline — unbounded recursive restart.

    async function retryRejectedPipeline(digitalTwin, dtName, backend, config, pipelineId) {
      await delay(PIPELINE_ACCEPTANCE_DELAY);
      const status = await backend
        .getPipelineStatus(projectId, pipelineId)
        .catch(() => 'pending');
      if (!isFailureStatus(status) && !isCanceledStatus(status)) return pipelineId;
      await backend.api.cancelPipeline(projectId, pipelineId).catch(() => {});
      return startPipeline(digitalTwin, dtName, config);
    }
    • Fail-safe default is good: if the status check itself errors, it's
      treated as 'pending' rather than triggering a spurious restart.
    • However, there is no visible cap on how many times this can recurse.
      If GitLab persistently rejects the pipeline for a non-transient
      reason (bad config, quota, permissions), this will keep calling
      startPipeline — i.e. keep triggering real new pipeline runs
      against GitLab — indefinitely. The PR description's safety claim
      ("requests that start a pipeline are never retried, so this cannot
      create duplicate jobs") is about network-level retry of the start
      call, but this is a logically distinct retry path (application-level
      "rejected, so start a fresh one") that isn't covered by that
      invariant and isn't visibly bounded. Recommend a max-attempt guard.
  5. initializeBackend retry wrapping looks appropriate.

    return retryRequest(async () => {
      const backend = createGitlabInstance(username, oauthToken, getAuthority());
      await backend.init();
      return backend;
    });

    Wrapping instance creation/init (idempotent, read-oriented) in retry is
    consistent with the stated "retry read-only requests only" principle.
    startPipeline (which calls digitalTwin.execute(...), a real trigger)
    is correctly not wrapped in retryRequest.

  6. Open question — the retry/idempotency boundary is enforced where,
    exactly?
    requestRetry.ts itself was not retrievable in this
    session. The entire safety argument in the PR description rests on
    "pipeline-starting calls are never retried." Whether that's structurally
    enforced (e.g. retryRequest only exposed to read-style call sites, or
    takes an explicit idempotency flag) or just a convention every future
    caller has to remember matters a lot for long-term safety — a future
    contributor wrapping a mutating call in retryRequest "for
    resilience" would silently reintroduce duplicate-pipeline risk. This is
    worth a direct look at that file before merge; it could not be verified
    here despite multiple fetch attempts.

Type safety / API boundary

  1. Unchecked cast on external API response.
    const bridges = (await this.client.Jobs.allPipelineBridges(
      projectId,
      pipelineId,
    )) as BridgeSchema[];
    as BridgeSchema[] trusts the shape of GitLab's response without
    runtime validation. Downstream access is safely optional-chained
    (bridge.downstream_pipeline?.id ?? null), so this won't crash, but if
    GitLab's API shape changes it will fail silently (treated as "no
    child pipeline found yet") rather than surfacing an error — which
    could manifest as the polling loop in point 2 running until timeout
    instead of failing fast with a clear cause.

Test/CI changes

  1. Playwright parallelization (playwright.config.ts). Moves
    previously workers: 1 sequential pipeline-dependent tests to shared
    worker capacity, and the firefox-sequential project's dependency
    changes from ['chromium-sequential'] to ['setup'] — i.e. firefox
    tests no longer wait on chromium tests to finish, they run off the
    base setup directly. This is consistent with "tests now run with three
    workers" from the PR description. Given point 2's concern about GitLab
    API load, running these against a real GitLab instance with more
    parallelism increases the chance of hitting the exact race conditions
    this PR is fixing — which is a reasonable way to validate the fix, but
    also means flaky-test risk if GitLab runner contention wasn't fully
    addressed elsewhere (timeout commits in this PR — "Prolong timeouts",
    "Extend e2e test timeout" — suggest this was already an iteration
    point during development).

Recommendation

The core fix (bridge-based child pipeline resolution) is correct and
should be merged in principle. Before merge, resolve:

  • confirm requestRetry.ts structurally prevents pipeline-starting calls
    from being retried (not just by convention)
  • add a max-attempt bound to retryRejectedPipeline's recursive restart
  • add error logging to the swallowed catches in cancelPipelineAndChild

…GitLab errors safely; warn when a job cannot be stopped; make shared job-history checks reliable; add comments otherwise.
@sonarqubecloud

sonarqubecloud Bot commented Aug 1, 2026

Copy link
Copy Markdown

@prasadtalasila

Copy link
Copy Markdown
Contributor

@atomicgamedeveloper please see this updates review

Full Code Review — PR #1737 "Fix child pipeline lookup" (Updated)

INTO-CPS-Association/DTaaS — atomicgamedeveloper:pipeline-child-id-fix → feature/distributed-demo
Reviewed at commit 0d99306 (7 commits, +736/−205, 35 files changed)

What changed since the last review

The PR now includes four additional commits beyond what was previously
reviewed:

  • 528fde9 — "Add Gitlab read only requests resend on failure up to 2 times"
  • b739f15 — "Set amount of workers globally"
  • 5f77c4f — "Extend e2e test timeout"
  • 0d99306 — automated qlty fmt

The PR description now explicitly states the retry policy: a failed
read-only GitLab request waits 250 ms and retries up to two times
, and
reiterates that pipeline-starting requests are never retried. This
directly answers the open question from the prior review about whether
retryRequest is bounded — it is bounded by count (2 retries), per the
author's own description. The requestRetry.ts implementation itself
was still not retrievable in this session (GitHub's diff view
consistently truncates before reaching it), so the cap is confirmed
via the PR description and test file name (requestRetry.test.ts
exists), but the enforcement mechanism (is it structurally limited to
read-only call sites, or just used that way by convention?) still
couldn't be directly inspected.

CodeCov confirms requestRetry.ts is part of this PR's diff at 100%
patch coverage, and a dedicated requestRetry.test.ts was added — good
signal that the retry-count boundary itself is under test.

Summary

Replaces the previous getChildPipelineId(parentId) = parentId + 1
heuristic with a real lookup through GitLab's pipeline bridges API
(Jobs.allPipelineBridgesdownstream_pipeline.id). Also adds:

  • retry-on-failure for read-only GitLab calls, capped at 2 retries with
    a 250ms delay (retryRequest, used in initializeBackend)
  • a bounded polling loop to resolve the child pipeline ID
    (resolveChildPipelineId)
  • a "pipeline rejected → cancel and restart" recovery path
    (retryRejectedPipeline), gated by a new 10-second
    PIPELINE_ACCEPTANCE_DELAY
  • parallelized E2E Playwright config: the chromium-sequential /
    firefox-sequential projects lose their workers: 1 cap and
    firefox-sequential now depends on ['setup'] instead of
    ['chromium-sequential'] — decoupling the two browsers so they run
    concurrently rather than firefox waiting for chromium
  • extended e2e test timeouts to accommodate the above

Files reviewed

Confirmed from diff: playwright.config.ts, backend.ts, instance.ts,
pipelineCore.ts, measure/constants.ts, measure/measurement.pipeline.ts.
Not retrievable in this session despite repeated attempts (GitHub's
diff view truncates at a fixed point regardless of which file anchor is
requested): requestRetry.ts, measurement.runner.ts,
measurement.types.ts, ExecutionStatusService.ts,
executionStatusManager.ts, digitalTwinPipelineExecution.ts,
init.ts, backendInterfaces.ts, mockBackendData.ts, and all test
files (requestRetry.test.ts, measurement.pipeline.test.ts,
childPipeline.test.ts/.test.tsx, e2e tests, etc.). Findings below
are scoped strictly to what was actually visible; items depending on
the unseen files are marked as open questions, not confirmed defects.

Correctness / design

  1. Core fix is sound. parentId + 1 was never a safe way to
    identify a child pipeline once pipelines can be created concurrently
    — the "+1" pipeline could belong to a different, unrelated run.
    Using GitLab's bridge job data (downstream_pipeline.id) asks GitLab
    for ground truth instead of guessing, closing what was effectively a
    cross-pipeline status/cancel/log confusion bug (issue [BUG] Pipeline execution assumes child pipeline ID is always parent ID + 1 #1705). The
    old getChildPipelineId(parentPipelineId) => parentPipelineId + 1
    helper in pipelineCore.ts has been fully removed, and all call
    sites route through the new bridge-based lookup — good, no
    leftover dead paths using the old heuristic.

  2. resolveChildPipelineId is a correctly-bounded polling loop.

    for (;;) {
      const childPipelineId = await backend.getChildPipelineId(projectId, parentPipelineId);
      if (childPipelineId != null) return childPipelineId;
      ensurePipelineCanContinue(parentPipelineId, startTime);
      await delay(PIPELINE_POLL_INTERVAL);
    }

    ensurePipelineCanContinue enforces both a user-abort check and a
    hasTimedOut(startTime, MAX_EXECUTION_TIME) timeout, so the loop
    cannot run forever under normal conditions. Two follow-ups remain:

    • backend.getChildPipelineId is called directly here, not through
      retryRequest. Given retryRequest now has a confirmed 2-retry/
      250ms policy for read-only calls, and this is unambiguously a
      read-only call made repeatedly in a hot polling loop, it's the
      clearest candidate in the whole PR for using that wrapper but
      apparently doesn't — a single transient GitLab blip here still
      falls straight through to the next poll iteration rather than
      benefiting from the fast in-place retry, which is a minor
      inconsistency rather than a bug (the poll loop's own retry-via-
      polling somewhat substitutes for it), but is still worth a note to
      the author for consistency.
    • Every iteration issues a fresh bridges API call. Combined with the
      new uncapped chromium/firefox parallelism, this raises GitLab API
      call volume under concurrent pipeline execution. Worth confirming
      PIPELINE_POLL_INTERVAL is generous enough to stay well under
      GitLab's rate limits at higher concurrency.
  3. cancelPipelineAndChild swallows errors silently, but this is now
    the sole cancellation path (a cleanup, not a duplication).

    async function cancelPipelineAndChild(backend, pipelineId) {
      try {
        const projectId = backend.getProjectId();
        await backend.api.cancelPipeline(projectId, pipelineId);
        const childPipelineId = await backend
          .getChildPipelineId(projectId, pipelineId)
          .catch(() => null);
        await cancelChildPipeline(backend, projectId, childPipelineId);
      } catch {
        // Continue with the remaining pipelines.
      }
    }
    
    export async function cancelActivePipelines(): Promise<void> {
      for (const { backend, pipelineId } of measurementState.activePipelines) {
        await cancelPipelineAndChild(backend, pipelineId);
      }
    }

    This is a nice simplification versus the prior version — the
    duplicated inline try/catch block in cancelActivePipelines has been
    extracted into cancelPipelineAndChild and is now reused, reducing
    drift risk between the two cancellation code paths. The bare
    catch {} (both the outer one and cancelChildPipeline's inner
    .catch(() => {})) still swallows errors with no logging — a failed
    cancel is invisible, so a pipeline could keep consuming runner/
    compute resources with no operator-visible trace of why. A
    debug/warn-level log on the caught error would preserve the fail-open
    behavior while making the failure diagnosable.

  4. retryRejectedPipeline — recursive restart, now gated by a fixed
    10s delay, but still no attempt cap.

    async function retryRejectedPipeline(digitalTwin, dtName, backend, config, pipelineId) {
      await delay(PIPELINE_ACCEPTANCE_DELAY); // now 10 * 1000 ms
      const status = await backend
        .getPipelineStatus(projectId, pipelineId)
        .catch(() => 'pending');
      if (!isFailureStatus(status) && !isCanceledStatus(status)) return pipelineId;
      await backend.api.cancelPipeline(projectId, pipelineId).catch(() => {});
      return startPipeline(digitalTwin, dtName, config);
    }
    • The new PIPELINE_ACCEPTANCE_DELAY = 10 * 1000 constant means each
      rejection cycle now costs at least 10 seconds before a restart is
      attempted — a reasonable throttle against tight-loop restarts, and
      an improvement over having no delay at all.
    • However, there is still no visible cap on how many times this can
      recurse. If GitLab persistently rejects the pipeline for a
      non-transient reason (bad config, quota, permissions), this will
      keep calling startPipeline — i.e., keep triggering real new
      pipeline runs against GitLab — once every 10+ seconds, indefinitely.
      10-second spacing makes this much less aggressive than the
      tight-loop case flagged previously, but "no cap" is still "no cap":
      a genuinely broken pipeline configuration could still generate an
      unbounded number of GitLab pipeline runs over a long enough
      session, which is the opposite of the PR's stated "never create
      duplicate jobs" invariant — just realized through a different
      mechanism (application-level restart-on-rejection, not network
      retry). Recommend a max-attempts guard regardless of the delay.
    • Fail-safe default is still good: if the status check itself errors,
      it's treated as 'pending' rather than triggering a spurious
      restart.
  5. startPipeline extraction is a clean refactor. The pipeline-start
    logic (digitalTwin.execute(...) + null check) has been pulled out
    of executeDigitalTwinPipeline into its own startPipeline function,
    and is now called once directly and once again from within
    retryRejectedPipeline. This is good — it centralizes the one
    "must never be retried automatically by network-level retry" call
    site, making it easier to audit that retryRequest is never
    accidentally wrapped around it. Confirmed: startPipeline is not
    wrapped in retryRequest anywhere in the visible diff.

  6. initializeBackend retry wrapping looks appropriate and unchanged
    from the prior version.

    return retryRequest(async () => {
      const backend = createGitlabInstance(username, oauthToken, getAuthority());
      await backend.init();
      return backend;
    });

    Wrapping instance creation/init (idempotent, read-oriented) in retry
    is consistent with the stated "retry read-only requests only"
    principle.

  7. Open question — is the read/write retry boundary structurally
    enforced?
    requestRetry.ts itself remains unretrieved. The PR
    description now gives concrete parameters (250ms delay, 2 retries),
    which is good specificity, and a dedicated requestRetry.test.ts was
    added — but whether the function itself prevents being wrapped
    around a mutating call (by type signature, naming convention, or
    restricted export) versus relying purely on every future caller
    remembering not to use it for writes, could not be confirmed. This
    remains the single most important file to review directly before
    merge, given the whole "no duplicate pipelines" safety argument rests
    on it.

Type safety / API boundary

  1. Unchecked cast on external API response — unchanged.
    const bridges = (await this.client.Jobs.allPipelineBridges(
      projectId,
      pipelineId,
    )) as BridgeSchema[];
    as BridgeSchema[] trusts the shape of GitLab's response without
    runtime validation. Downstream access is safely optional-chained
    (bridge.downstream_pipeline?.id ?? null), so this won't crash, but
    a GitLab API shape change would fail silently (treated as "no child
    pipeline yet") rather than surfacing an error, which could manifest
    as the poll loop in point 2 running until timeout instead of failing
    fast with a clear cause.

Test/CI changes

  1. Playwright parallelization is now more aggressive than in the
    previous version reviewed.
    The earlier diff already removed the
    sequential-worker comment block; this version confirms the actual
    mechanical changes: workers: 1 is dropped from both
    chromium-sequential and firefox-sequential projects, and
    firefox-sequential's dependencies changed from
    ['chromium-sequential'] to ['setup']. Net effect: pipeline-
    dependent E2E tests across both browsers can now run fully
    concurrently against the same GitLab instance, rather than one
    browser's suite waiting for the other's to finish. Combined with the
    new retryRequest policy, this is a coherent pairing — the tests
    most likely to hit transient GitLab flakiness under new concurrency
    are exactly the ones that now benefit from automatic retry on
    read-only calls. The added 5f77c4f "Extend e2e test timeout" commit
    is a reasonable accompanying safety margin for this change.

  2. Coverage note (from CodeCov). Patch coverage is 97.02%, with 5
    lines uncovered across measurement.pipeline.ts (4 lines) and
    digitalTwinPipelineExecution.ts (1 line). Worth asking the author
    which specific branches are uncovered in measurement.pipeline.ts
    given the density of new error-handling paths added in this file
    (multiple .catch(() => ...) fallbacks), it's plausible the
    uncovered lines are exactly the less-common failure branches (e.g.,
    the 'pending' fallback in retryRejectedPipeline, or one arm of
    cancelChildPipeline), which are also the highest-risk paths from a
    review perspective.

Recommendation

The core fix (bridge-based child pipeline resolution) remains correct
and is a genuine improvement, and this update adds real hardening
(bounded retry policy, 10s acceptance delay, parallel E2E coverage of
the new concurrency path). Before merge, still resolve:

  • confirm requestRetry.ts structurally prevents pipeline-starting
    calls from being retried (not just by convention) — could not verify
    directly this session
  • add a max-attempt bound to retryRejectedPipeline's recursive
    restart, independent of the 10s spacing
  • add error logging to the swallowed catches in cancelPipelineAndChild
    / cancelChildPipeline
  • consider wrapping the poll loop's getChildPipelineId call in the
    same retryRequest policy used for initializeBackend, for
    consistency

Do tag me when you have reaolved the comments by both copilot and claude. Thanks.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.

Suppressed comments (3)

client/src/route/digitaltwins/execution/executionStatusManager.ts:255

  • checkChildPipelineStatus re-fetches the child pipeline ID on every poll. This can unnecessarily hit GitLab, and a transient getChildPipelineId failure will currently reject the whole polling loop. Cache the child pipeline ID once discovered and treat lookup errors as retryable.
  while (true) {
    const pipelineId = await digitalTwin.backend.getChildPipelineId(
      digitalTwin.backend.getProjectId(),
      parentPipelineId,
    );

client/src/route/digitaltwins/execution/executionStatusManager.ts:276

  • getPipelineStatus errors (e.g. transient network failures) currently bubble up and abort status polling. Since this is part of a polling loop, treat request failures as retryable (keep polling until timeout) rather than crashing the status manager.
    const pipelineStatus = await digitalTwin.backend.getPipelineStatus(
      digitalTwin.backend.getProjectId(),
      pipelineId,
    );

client/src/route/digitaltwins/execution/executionStatusManager.ts:1

  • File-wide eslint-disable can mask unrelated issues elsewhere in this module. Prefer scoping the suppression to the specific polling loop (or a small block) so other rules still apply to the rest of the file.
/* eslint-disable no-await-in-loop, no-continue */

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Pipeline execution assumes child pipeline ID is always parent ID + 1

3 participants