From 33ee4943fc029ad829c1b2cf208abc6025a050f0 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 1 May 2026 19:11:32 +0200 Subject: [PATCH] perf: bounded concurrency for synchronizeAllRepositories (3-way) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Wave-1 Performance and Probot/Octokit agents flagged this as Bug #21 in `docs/agent-fleet/bugs.md`. The org-wide sweep runs `configureRepository` fully serially: ~30-60 s for 13 repos, ~5+ minutes for 100. Each repo config issues 4-6 GitHub API calls; serial means most of that wall time is round-trip latency, not work. Fully parallel risks blowing the GitHub 5000/h core budget on a 100-repo org and saturating the 2-core netcup VPS. The textbook answer is bounded concurrency. ## What Inline `pLimitMap(items, concurrency, fn)` helper (~12 lines, no dependency). Workers pull from a shared cursor; bounded by `concurrency`. Used at `concurrency=3` — enough to overlap GitHub round-trips, light enough to leave headroom for the webhook hot path. The sequential filtering of `archived` and `isControlSurfaceRepo` repos is preserved before dispatch, so per-iteration log lines stay readable. ## Source Bug #21, wave-1 Performance / Probot expert (`docs/agent-fleet/bugs.md`). ## Test plan - [x] 834 tests pass - [x] eslint clean - [ ] After merge + a `/sync-all-repos` invocation in temper-ops: wall time should drop from ~30 s → ~10-15 s for 13-repo org. Visible in the bot's "Synchronized X repositories" reply timing. ## Risk & rollout - Risk: low. Same per-repo behaviour, just parallelised. Each repo's failure is still locally caught and logged. Failure of one doesn't cancel others (was true before too). - Rollout: self-update on merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/integration/organization.test.js | 2 +- src/organization.js | 39 ++++++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/__tests__/integration/organization.test.js b/__tests__/integration/organization.test.js index d754f7c..815bf6c 100644 --- a/__tests__/integration/organization.test.js +++ b/__tests__/integration/organization.test.js @@ -227,7 +227,7 @@ describe('organization', () => { 'Starting synchronization for organization: test-org' ); expect(getLogger().info).toHaveBeenCalledWith( - 'Found 0 repositories to synchronize' + expect.stringMatching(/Found 0 repositories to synchronize/) ); expect(getLogger().info).toHaveBeenCalledWith( expect.stringContaining('Completed synchronization for organization: test-org') diff --git a/src/organization.js b/src/organization.js index 8ce259c..f406253 100644 --- a/src/organization.js +++ b/src/organization.js @@ -19,6 +19,31 @@ async function checkOrganizationMembership(octokit, org, username) { } } +/** + * Bounded-concurrency map. Runs `fn(item)` over `items` with at most + * `concurrency` calls in flight. Resolves to an array of results in the + * same order as `items`. No new dependency. + * + * Used by `synchronizeAllRepositories` to avoid the previous fully-serial + * loop (~30-60 s for 13 repos) without going fully parallel (which would + * blow the GitHub 5000/h rate budget on a 100-repo org). + */ +async function pLimitMap(items, concurrency, fn) { + const results = new Array(items.length); + let cursor = 0; + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + let i = cursor++; + while (i < items.length) { + results[i] = await fn(items[i], i); + i = cursor++; + } + }); + await Promise.all(workers); + return results; +} + +const SYNC_CONCURRENCY = 3; + async function synchronizeAllRepositories(octokit, org) { try { getLogger().info(`Starting synchronization for organization: ${org}`); @@ -29,19 +54,21 @@ async function synchronizeAllRepositories(octokit, org) { per_page: 100 }); - getLogger().info(`Found ${repos.length} repositories to synchronize`); + getLogger().info(`Found ${repos.length} repositories to synchronize (concurrency=${SYNC_CONCURRENCY})`); - for (const repo of repos) { + const todo = repos.filter((repo) => { if (repo.archived) { getLogger().info(`Skipping archived repository: ${repo.full_name}`); - continue; + return false; } - if (isControlSurfaceRepo(repo.full_name)) { getLogger().info(`Skipping control-surface repository: ${repo.full_name}`); - continue; + return false; } + return true; + }); + await pLimitMap(todo, SYNC_CONCURRENCY, async (repo) => { getLogger().info(`Processing repository: ${repo.full_name}`); const configResult = await configureRepository(octokit, repo); if (!configResult.success) { @@ -49,7 +76,7 @@ async function synchronizeAllRepositories(octokit, org) { } else { getLogger().info(`✅ Synchronized ${repo.full_name}`); } - } + }); getLogger().info(`✅ Completed synchronization for organization: ${org}`); return { success: true, repositoriesProcessed: repos.length };