Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ All notable changes to CloudVoyager are documented in this file. Entries are ord

---

## Parallel Issue Sync with Worker Threads (2026-04-28)
<!-- updated: 2026-04-28_11:30:00 -->

Added `worker_threads`-based parallel issue sync to dramatically reduce wall-clock time for large projects (e.g., Angular Framework with 31,622 issues — from ~90 minutes to ~5-10 minutes).

**Problem:** Issue sync was bottlenecked on SonarCloud API response times. A single Node.js process with `mapConcurrent` at concurrency=20 couldn't push past 20 concurrent HTTP requests, and the single event loop serialized callback processing.

**Solution:** For projects with ≥500 matched pairs, spawn N worker threads (default 20) using `worker_threads` with `eval: true` (SEA-compatible — no file system access needed). Each worker runs self-contained inline code using only Node.js built-in `https`/`http` modules, with an internal concurrency pool of 5 issues each. Total concurrent API calls: 20×5 = 100 (vs 20 previously).

**Design decisions:**
- `eval: true` workers with inline code string — works inside the SEA binary without `require()` access to bundled modules
- Round-robin partitioning distributes "heavy" issues (many comments/transitions) evenly across workers
- Exponential backoff retry (3 attempts, 1s/2s/4s) in each worker handles 429 rate limiting
- Falls back to existing `mapConcurrent` path for <500 matched pairs (worker spawn overhead not worth it)
- Worker count and per-worker concurrency are configurable

**Files changed:**
- `src/shared/utils/concurrency/helpers/parallel-issue-sync.js` — new file: parent orchestrator + inline worker code
- `src/shared/utils/concurrency/index.js` — added `parallelSyncIssues` export
- `src/pipelines/sq-2025/sonarcloud/migrators/issue-sync/index.js` — conditional parallel dispatch for ≥500 pairs

---

## Accurate Issue Creation Date Backdating (2026-04-28)
<!-- updated: 2026-04-28_00:45:00 -->

Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ src/
├── utils/ # Utility modules
│ ├── logger.js # Winston-based logging
│ ├── errors.js # Custom error classes (11 error classes extending CloudVoyagerError)
│ ├── concurrency.js # Concurrency primitives (limiter, mapConcurrent, progress)
│ ├── concurrency.js # Concurrency primitives (limiter, mapConcurrent, progress, parallelSyncIssues)
│ ├── system-info.js # System info detection (CPU, memory) and auto-tune
│ ├── shutdown.js # Graceful SIGINT/SIGTERM shutdown coordinator
│ ├── progress.js # Checkpoint progress display and ETA
Expand Down Expand Up @@ -411,6 +411,8 @@ CloudVoyager uses a zero-dependency concurrency layer (`src/shared/utils/concurr

Extractors and migrators use `mapConcurrent` to parallelize HTTP calls (source file fetching, hotspot detail fetching, issue/hotspot sync). Each version-specific `migrate-pipeline.js` resolves performance config and passes concurrency settings to all operations.

- **`parallelSyncIssues(matchedPairs, ...)`** — For large issue sets (≥500 pairs), spawns `worker_threads` with `eval: true` (SEA-compatible) to run 20 workers × 5 concurrency each = 100 concurrent API calls. Falls back to `mapConcurrent` for smaller sets.

<!-- Updated: Mar 25, 2026 -->
## 📦 Build and Packaging

Expand Down
19 changes: 19 additions & 0 deletions docs/key-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,25 @@ Different operations have different optimal concurrency levels, reflecting API r

Long-running concurrent operations emit progress logs (e.g., "Fetching sources: 142/350 completed") via `createProgressLogger`, giving operators real-time visibility into migration progress.

### Parallel Issue Sync with Worker Threads
<!-- updated: 2026-04-25_20:30:00 -->

For large projects (≥500 matched issue pairs), issue sync is parallelized across `worker_threads` to overcome the single event loop bottleneck:

- **`parallelSyncIssues()`** — Spawns N worker threads (default 20), each with an internal concurrency pool of 5, achieving **100 concurrent API calls** (vs 20 with single-process `mapConcurrent`)
- **SEA-compatible**: Workers use `eval: true` with inline code (~200 lines) using only Node.js built-in `https`/`http` modules — no file system access or bundled module `require()` needed
- **Round-robin partitioning**: Distributes matched pairs evenly across workers so "heavy" issues (many comments/transitions) don't cluster
- **Rate limit resilience**: Each worker retries with exponential backoff (3 attempts, 1s/2s/4s) on 429/transient errors
- **Configurable**: `workerCount` (default 20) and `concurrencyPerWorker` (default 5) are tunable
- **Automatic fallback**: Projects with <500 matched pairs use the existing `mapConcurrent` path

| Parameter | Default | Effect |
|-----------|---------|--------|
| `workerCount` | 20 | Number of worker threads |
| `concurrencyPerWorker` | 5 | Concurrent issues per worker |
| Total concurrent requests | 100 | `workerCount × concurrencyPerWorker` |
| Activation threshold | 500 | Minimum matched pairs to trigger parallel sync |

---

<!-- Updated: Feb 20, 2026 at 04:02:35 PM -->
Expand Down
12 changes: 12 additions & 0 deletions docs/technical-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,18 @@ The pre-filter is implemented across three shared utilities:
- `has-manual-changes.js` — pure function; returns `true` if any of the above conditions holds
- `apply-pre-filter.js` — orchestrates the above two and sets `stats.filtered` with the skipped count

### Parallel Issue Sync (≥500 matched pairs)
<!-- updated: 2026-04-25_20:30:00 -->

When the matched pair count reaches 500+, the syncer switches from single-process `mapConcurrent` to `worker_threads`-based parallelism (`src/shared/utils/concurrency/helpers/parallel-issue-sync.js`):

1. **Partition** — Matched pairs are distributed round-robin across 20 workers (~1,581 each for 31K issues)
2. **Spawn** — Each worker is a `Worker` with `eval: true`, receiving a self-contained code string that uses only `https`/`http` built-ins (SEA-compatible)
3. **Execute** — Each worker runs 5 concurrent issue syncs internally (status transitions, assignment, comments, tags, source link)
4. **Aggregate** — Parent collects progress messages and merges final stats from all workers

Total concurrent API calls: 20 workers × 5 internal = **100** (vs 20 with single-process). Each worker includes exponential backoff retry for 429/transient errors.

### Wait for SC Indexing: `waitForScIndexing`

`src/shared/utils/issue-sync/wait-for-sc-indexing.js` wraps any SC fetch call with retry logic:
Expand Down
24 changes: 17 additions & 7 deletions src/pipelines/sq-2025/sonarcloud/migrators/issue-sync/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logger from '../../../../../shared/utils/logger.js';
import { mapConcurrent, createProgressLogger } from '../../../../../shared/utils/concurrency.js';
import { parallelSyncIssues } from '../../../../../shared/utils/concurrency/helpers/parallel-issue-sync.js';
import { applyManualChangesPreFilter } from '../../../../../shared/utils/issue-sync/apply-pre-filter.js';
import { waitForScIndexing } from '../../../../../shared/utils/issue-sync/wait-for-sc-indexing.js';
import { matchIssues } from './helpers/match-issues.js';
Expand Down Expand Up @@ -49,12 +50,20 @@ export async function syncIssues(projectKey, sqIssues, client, options = {}) {
return stats;
}

logger.info(`Syncing ${matchedPairs.length} issues with concurrency=${concurrency}`);
await mapConcurrent(
matchedPairs,
async (pair) => syncOneIssue(pair, client, sqClient, userMappings, stats, changelogMap),
{ concurrency, settled: true, onProgress: createProgressLogger('Issue sync', matchedPairs.length) },
);
const PARALLEL_THRESHOLD = 500;
if (matchedPairs.length >= PARALLEL_THRESHOLD) {
const scConfig = { baseURL: client.baseURL, token: client.token, organization: client.organization, projectKey };
const sqClientConfig = sqClient ? { baseURL: sqClient.baseURL, token: sqClient.token, projectKey: sqClient.projectKey } : null;
const mergedStats = await parallelSyncIssues(matchedPairs, changelogMap, scConfig, sqClientConfig, userMappings);
Object.assign(stats, mergedStats);
} else {
logger.info(`Syncing ${matchedPairs.length} issues with concurrency=${concurrency}`);
await mapConcurrent(
matchedPairs,
async (pair) => syncOneIssue(pair, client, sqClient, userMappings, stats, changelogMap),
{ concurrency, settled: true, onProgress: createProgressLogger('Issue sync', matchedPairs.length) },
);
}

logSyncSummary(stats);
return stats;
Expand All @@ -65,5 +74,6 @@ function logSyncSummary(stats) {
const filtered = stats.filtered > 0 ? `${stats.filtered} filtered, ` : '';
const mapped = stats.assignmentMapped > 0 ? `, ${stats.assignmentMapped} mapped` : '';
const skipped = stats.assignmentSkipped > 0 ? `, ${stats.assignmentSkipped} assignment-skipped` : '';
logger.info(`Issue sync: ${filtered}${stats.matched} matched, ${stats.transitioned} transitioned, ${stats.assigned} assigned${mapped}, ${stats.assignmentFailed} assignment-failed${skipped}, ${stats.commented} comments, ${stats.tagged} tagged, ${stats.metadataSyncTagged} metadata-sync-tagged, ${stats.sourceLinked} source-linked, ${stats.failed} failed`);
const apiErr = stats.apiErrors > 0 ? `, ${stats.apiErrors} api-errors` : '';
logger.info(`Issue sync: ${filtered}${stats.matched} matched, ${stats.transitioned} transitioned, ${stats.assigned} assigned${mapped}, ${stats.assignmentFailed} assignment-failed${skipped}, ${stats.commented} comments, ${stats.tagged} tagged, ${stats.metadataSyncTagged} metadata-sync-tagged, ${stats.sourceLinked} source-linked, ${stats.failed} failed${apiErr}`);
}
Loading