From 43b7e7669e9c3842cfaf58a1d72fc7a37d62b134 Mon Sep 17 00:00:00 2001 From: swinston Date: Sat, 30 Aug 2025 02:00:19 -0700 Subject: [PATCH 01/12] Revert "Update dependencies and fix web manifest path" This reverts commit 7daf96271cbf3425550da8f950f0c32a2f68e117. --- docs-site/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/package.json b/docs-site/package.json index 0be81fee14..6ac2f0314e 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -1,7 +1,7 @@ { "devDependencies": { "@antora/cli": "3.1.4", - "@antora/lunr-extension": "khronosgroup/antora-lunr-extension#cb9ee28eb66fda40b34b8119cf270dc4c0a2d015", + "@antora/lunr-extension": "gpx1000/antora-lunr-extension#2fb7fc95869f715f62654d29d5c585e37a477a55", "@antora/site-generator": "3.1.10", "@djencks/asciidoctor-mathjax": "0.0.9", "escape-string-regexp": "^5.0.0", From 2ad2f4553c2fd09b613885ce2e4a66fa1a210c29 Mon Sep 17 00:00:00 2001 From: swinston Date: Sat, 30 Aug 2025 02:09:31 -0700 Subject: [PATCH 02/12] Update @antora/lunr-extension to use Khronos Group repository --- docs-site/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/package.json b/docs-site/package.json index 6ac2f0314e..0be81fee14 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -1,7 +1,7 @@ { "devDependencies": { "@antora/cli": "3.1.4", - "@antora/lunr-extension": "gpx1000/antora-lunr-extension#2fb7fc95869f715f62654d29d5c585e37a477a55", + "@antora/lunr-extension": "khronosgroup/antora-lunr-extension#cb9ee28eb66fda40b34b8119cf270dc4c0a2d015", "@antora/site-generator": "3.1.10", "@djencks/asciidoctor-mathjax": "0.0.9", "escape-string-regexp": "^5.0.0", From d4f6764f21c422b29384922e7229896edcb40853 Mon Sep 17 00:00:00 2001 From: swinston Date: Tue, 2 Sep 2025 11:31:58 -0700 Subject: [PATCH 03/12] Add sources-parallel Antora extension scaffold Introduce initial scaffold for the sources-parallel extension to enable per-source/module parallel execution in Antora. Updated playbook, scripts, and Makefile to integrate the extension, while keeping it safe/inert for future parallelism iterations. --- Makefile | 8 ++--- antora-ui-khronos | 2 +- docs-site/antora-playbook.yml | 3 ++ docs-site/js/sources-parallel.js | 54 ++++++++++++++++++++++++++++++++ docs-site/package.json | 6 +++- 5 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 docs-site/js/sources-parallel.js diff --git a/Makefile b/Makefile index 532505c3e8..66e7562dcf 100644 --- a/Makefile +++ b/Makefile @@ -79,22 +79,22 @@ prep-docs: docs-site/js/ prep-glsl: - make -C GLSL clean setup_antora + $(MAKE) -C GLSL clean setup_antora prep-guide: - make -C Vulkan-Guide -f antora/Makefile clean setup + $(MAKE) -C Vulkan-Guide -f antora/Makefile clean setup prep-samples: cd Vulkan-Samples && cmake -H"." -B"build/unix" -DVKB_GENERATE_ANTORA_SITE=ON prep-tutorial: - make -C Vulkan-Tutorial/antora setup_tutorial + $(MAKE) -C Vulkan-Tutorial/antora setup_tutorial # Build Antora site # CI is needed as an environment variable which helps cause suppression # of the "Edit this Page" link otherwise generated. export CI = true -build-site: +build-site: build-ui prep-sources cd docs-site && npx antora antora-playbook.yml --stacktrace # Clean Antora site (but not prepared component sources) diff --git a/antora-ui-khronos b/antora-ui-khronos index ac0b12aadf..67fb0f7cb9 160000 --- a/antora-ui-khronos +++ b/antora-ui-khronos @@ -1 +1 @@ -Subproject commit ac0b12aadf11db0625445dd5c3eaf374f1176df8 +Subproject commit 67fb0f7cb94ee9993dd3f1dac86d6295016c4901 diff --git a/docs-site/antora-playbook.yml b/docs-site/antora-playbook.yml index f007488b49..d14f0aa2da 100644 --- a/docs-site/antora-playbook.yml +++ b/docs-site/antora-playbook.yml @@ -31,6 +31,9 @@ antora: extensions: - require: '@antora/lunr-extension' index_latest_only: true + - require: antora-sources-parallel + sources_parallel: true + experimental_fanout: true asciidoc: extensions: # specmacros.js requires './apimap.cjs', 'xrefMap.cjs', and 'pageMap.cjs'. diff --git a/docs-site/js/sources-parallel.js b/docs-site/js/sources-parallel.js new file mode 100644 index 0000000000..989140c9cc --- /dev/null +++ b/docs-site/js/sources-parallel.js @@ -0,0 +1,54 @@ +/* + * Antora extension: sources-parallel + * Goal: Provide an extension hook and configuration to enable per-source/module parallel execution. + * Note: This initial scaffold computes worker count and logs activation; it does not yet override the + * generator pipeline. It is designed to remain safe/inert while we iterate on true parallelism. + */ + +const os = require('os') + +function computeWorkers (min = 3) { + const cores = Array.isArray(os.cpus()) ? os.cpus().length : 1 + return Math.max(min, cores || 1) +} + +module.exports = function (registry) { + // Antora passes the playbook to hooks; we use playbookBuilt to read extension options + const logger = console + + registry.on('playbookBuilt', ({ playbook }) => { + try { + // Locate our extension options if provided via the playbook extensions entry + // When listed as: { require: './js/sources-parallel.js', sources_parallel: true, max_workers: N } + // Antora makes extension options available via playbook.get('antora.extensions') for some APIs; + // since this is not a public API surface, we defensively parse from the raw object when possible. + const raw = playbook && (playbook.asMutable?.() || playbook) + const extensions = raw?.antora?.extensions || [] + const selfEntry = Array.isArray(extensions) + ? extensions.find((e) => (e && (e.require === './js/sources-parallel.js' || e.require === 'js/sources-parallel.js'))) + : undefined + + const enabled = !!(selfEntry && (selfEntry.sources_parallel === true || selfEntry.enabled === true)) + const minWorkers = typeof selfEntry?.min_workers === 'number' ? selfEntry.min_workers : 3 + const configuredMax = typeof selfEntry?.max_workers === 'number' ? selfEntry.max_workers : undefined + + const autoWorkers = computeWorkers(minWorkers) + const workers = configuredMax && configuredMax > 0 ? configuredMax : autoWorkers + + if (!enabled) { + logger.log(`[sources-parallel] Loaded (disabled). Computed workers=${workers}. No changes applied.`) + return + } + + // Expose workers via env to allow future cooperation with other extensions/tools + if (!process.env.ANTORA_SOURCES_PARALLEL_WORKERS) { + process.env.ANTORA_SOURCES_PARALLEL_WORKERS = String(workers) + } + + // Placeholder: future iterations will patch the generator to fan-out per-source work. + logger.log(`[sources-parallel] Enabled with workers=${workers}. Parallel generation hooks not yet active (scaffold).`) + } catch (e) { + logger.warn(`[sources-parallel] Failed to initialize: ${e && e.message ? e.message : e}`) + } + }) +} diff --git a/docs-site/package.json b/docs-site/package.json index 0be81fee14..6f3f6620db 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -7,7 +7,11 @@ "escape-string-regexp": "^5.0.0", "pako": "^2.1.0" }, + "dependencies": { + "antora-sources-parallel": "file:./extensions/antora-sources-parallel" + }, "scripts": { - "npx": "npx" + "npx": "npx", + "antora-fanout": "node ./extensions/antora-sources-parallel/bin/fanout.js antora-playbook.yml" } } From c6a29c8f9ca76b875353aa9c5a6b3152dbbbf229 Mon Sep 17 00:00:00 2001 From: swinston Date: Tue, 2 Sep 2025 11:42:25 -0700 Subject: [PATCH 04/12] Introduce `antora-sources-parallel` extension Added a new Antora extension `antora-sources-parallel` to enable parallel-friendly builds. Includes concurrency hints (Phase 1) and an experimental per-source fan-out orchestrator for parallel execution. Added documentation, sample configurations, and supporting scripts for integration. --- .../antora-sources-parallel/README.md | 69 +++++++ .../antora-sources-parallel/bin/fanout.js | 182 ++++++++++++++++++ .../antora-sources-parallel/index.js | 60 ++++++ .../antora-sources-parallel/package.json | 23 +++ 4 files changed, 334 insertions(+) create mode 100644 docs-site/extensions/antora-sources-parallel/README.md create mode 100644 docs-site/extensions/antora-sources-parallel/bin/fanout.js create mode 100644 docs-site/extensions/antora-sources-parallel/index.js create mode 100644 docs-site/extensions/antora-sources-parallel/package.json diff --git a/docs-site/extensions/antora-sources-parallel/README.md b/docs-site/extensions/antora-sources-parallel/README.md new file mode 100644 index 0000000000..62336ee278 --- /dev/null +++ b/docs-site/extensions/antora-sources-parallel/README.md @@ -0,0 +1,69 @@ +# antora-sources-parallel + +Antora extension to enable parallel-friendly builds. Phase 1 provides safe concurrency hints; a future phase can implement per-source fan-out. + +## Install / Use (local) + +In your Antora playbook: + +```yaml +antora: + extensions: + - require: ./extensions/antora-sources-parallel + sources_parallel: true + # Optional tuning: + # min_workers: 3 + # max_workers: 8 +``` + +This extension computes a worker count based on your CPU core count (or `min_workers`, default 3), and sets the following environment variables if not already set: +- `ANTORA_FETCH_CONCURRENCY` +- `ANTORA_CONCURRENCY` +- `ANTORA_SOURCES_PARALLEL_WORKERS` + +These hints can be used by Antora and cooperating extensions to perform work in parallel. + +## As a separate package + +When extracted to its own repository and published, you can use: + +```yaml +antora: + extensions: + - require: antora-sources-parallel + sources_parallel: true +``` + +## Experimental features + +- `experimental_fanout: true` is reserved for a future release supporting per-source fan-out. It is currently not implemented and ignored aside from a warning. + +## License + +Apache-2.0 + + +## Per-source fan-out (experimental) + +This package includes an optional fan-out orchestrator that builds each content source in parallel and merges outputs. + +How to use locally: + +- Ensure you are in the docs-site directory and have installed dependencies (npm install) +- Run: + +``` +npm run antora-fanout +``` + +What it does: +- Splits the playbook’s content.sources into separate temporary playbooks +- Runs `npx antora` for each in parallel (workers derived from CPU count or env), outputting to build/.fanout/ +- Merges all shard outputs into build/site + +Tuning: +- Set one of these env vars to control concurrency: ANTORA_SOURCES_PARALLEL_WORKERS, ANTORA_CONCURRENCY, ANTORA_FETCH_CONCURRENCY + +Notes: +- The standard build (npx antora antora-playbook.yml) remains unchanged; fan-out is opt-in +- Collisions in generated files are resolved "last writer wins" during merge diff --git a/docs-site/extensions/antora-sources-parallel/bin/fanout.js b/docs-site/extensions/antora-sources-parallel/bin/fanout.js new file mode 100644 index 0000000000..ce948fb06d --- /dev/null +++ b/docs-site/extensions/antora-sources-parallel/bin/fanout.js @@ -0,0 +1,182 @@ +#!/usr/bin/env node +/* + * antora-sources-parallel: per-source fan-out orchestrator (experimental) + * + * Usage (from docs-site/): + * node ./extensions/antora-sources-parallel/bin/fanout.js antora-playbook.yml + * + * Behavior: + * - Reads the provided playbook + * - Splits content.sources into one playbook per source + * - Runs `npx antora ` for each in parallel (bounded by worker limit) + * - Each run outputs into build/.fanout/ + * - After completion, merges all into build/site (last write wins on collisions) + * + * Notes: + * - This is an optional runner; it does not modify Antora itself. + * - Requires dev deps: @antora/cli, @antora/site-generator in docs-site + * - Uses js-yaml as a dependency of the local extension package. + */ + +const fs = require('fs') +const fsp = fs.promises +const path = require('path') +const { spawn } = require('child_process') +const yaml = require('js-yaml') +const os = require('os') + +function cpuWorkers (min = 3) { + const cores = Array.isArray(os.cpus()) ? os.cpus().length : 1 + return Math.max(min, cores || 1) +} + +function getWorkersFromEnv () { + const envKeys = ['ANTORA_SOURCES_PARALLEL_WORKERS', 'ANTORA_CONCURRENCY', 'ANTORA_FETCH_CONCURRENCY'] + for (const k of envKeys) { + const v = process.env[k] + if (v && +v > 0) return +v + } + return cpuWorkers(3) +} + +async function readYaml (file) { + const text = await fsp.readFile(file, 'utf8') + return yaml.load(text) +} + +async function writeYaml (file, obj) { + const text = yaml.dump(obj, { noRefs: true, lineWidth: 120 }) + await fsp.writeFile(file, text, 'utf8') +} + +async function rimraf (p) { + await fsp.rm(p, { recursive: true, force: true }) +} + +async function mkdirp (p) { + await fsp.mkdir(p, { recursive: true }) +} + +function runAntora (cwd, playbookPath) { + return new Promise((resolve, reject) => { + const child = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['antora', playbookPath, '--stacktrace'], { + cwd, + stdio: 'inherit', + env: process.env, + }) + child.on('exit', (code) => { + if (code === 0) resolve() + else reject(new Error(`antora exited with code ${code}`)) + }) + child.on('error', reject) + }) +} + +async function copyRecursive (src, dest) { + const st = await fsp.stat(src) + if (st.isDirectory()) { + await mkdirp(dest) + const entries = await fsp.readdir(src) + for (const e of entries) { + await copyRecursive(path.join(src, e), path.join(dest, e)) + } + } else if (st.isFile()) { + await mkdirp(path.dirname(dest)) + await fsp.copyFile(src, dest) + } +} + +async function mergeDirs (srcDir, dstDir) { + await mkdirp(dstDir) + const entries = await fsp.readdir(srcDir) + for (const e of entries) { + await copyRecursive(path.join(srcDir, e), path.join(dstDir, e)) + } +} + +async function main () { + const docsSiteDir = process.cwd() + const playbookArg = process.argv[2] || 'antora-playbook.yml' + const playbookPath = path.resolve(docsSiteDir, playbookArg) + + const playbook = await readYaml(playbookPath) + const sources = playbook?.content?.sources + if (!Array.isArray(sources) || sources.length === 0) { + throw new Error('No content.sources found in playbook') + } + + const workers = getWorkersFromEnv() + const fanoutRoot = path.join(docsSiteDir, 'build', '.fanout') + const finalOut = path.join(docsSiteDir, 'build', 'site') + + await rimraf(fanoutRoot) + await mkdirp(fanoutRoot) + + // Prepare per-source playbooks + const tasks = sources.map((src, idx) => ({ src, idx })) + + // Create temp playbooks with single source and per-run output dir + const tempPlaybooks = [] + for (const { src, idx } of tasks) { + const pb = JSON.parse(JSON.stringify(playbook)) + pb.content.sources = [src] + // Ensure ui bundle reference remains relative and output writes to unique dir + pb.output = pb.output || {} + pb.output.dir = path.join('build', '.fanout', String(idx)) + + const file = path.join(fanoutRoot, `playbook-${idx}.yml`) + await writeYaml(file, pb) + tempPlaybooks.push({ idx, file, outDir: path.join(docsSiteDir, pb.output.dir) }) + } + + // Run in parallel with a simple pool + let inFlight = 0 + let i = 0 + let failed = false + + await rimraf(finalOut) + await mkdirp(finalOut) + + await new Promise((resolve) => { + const next = () => { + if (failed) return + if (i >= tempPlaybooks.length && inFlight === 0) return resolve() + while (inFlight < workers && i < tempPlaybooks.length) { + const { file, idx } = tempPlaybooks[i++] + inFlight++ + runAntora(docsSiteDir, file) + .then(() => { + inFlight-- + next() + }) + .catch((err) => { + console.error(`[fanout] build failed for shard ${idx}:`, err.message) + failed = true + inFlight-- + next() + }) + } + } + next() + }) + + if (failed) { + process.exitCode = 1 + console.error('[fanout] One or more shards failed') + return + } + + // Merge outputs + for (const { outDir } of tempPlaybooks) { + // Expect outDir like build/.fanout/ + await mergeDirs(outDir, finalOut) + } + + console.log(`[fanout] Completed per-source build for ${tasks.length} sources with workers=${workers}`) + console.log(`[fanout] Output merged to: ${finalOut}`) +} + +main().catch((e) => { + console.error('[fanout] fatal:', e && e.message ? e.message : e) + process.exit(1) +}) diff --git a/docs-site/extensions/antora-sources-parallel/index.js b/docs-site/extensions/antora-sources-parallel/index.js new file mode 100644 index 0000000000..9aa06b3911 --- /dev/null +++ b/docs-site/extensions/antora-sources-parallel/index.js @@ -0,0 +1,60 @@ +/* + * Antora extension: antora-sources-parallel + * Goal: Provide an extension hook and configuration to enable per-source/module parallel execution. + * Phase 1: Safe concurrency activation via environment hints (fetch/build), with worker count policy. + * Phase 2 (future): Optional fan-out per-source builds and merge (experimental, off by default). + */ + +const os = require('os') + +function computeWorkers (min = 3) { + const cores = Array.isArray(os.cpus()) ? os.cpus().length : 1 + return Math.max(min, cores || 1) +} + +module.exports = function (registry) { + const logger = console + + registry.on('playbookBuilt', ({ playbook }) => { + try { + const raw = playbook && (playbook.asMutable?.() || playbook) + const extensions = raw?.antora?.extensions || [] + const selfEntry = Array.isArray(extensions) + ? extensions.find((e) => (e && (e.require === 'antora-sources-parallel' || e.require === './extensions/antora-sources-parallel' || e.require === './extensions/antora-sources-parallel/index.js' || e.require === './js/sources-parallel.js'))) + : undefined + + const enabled = !!(selfEntry && (selfEntry.sources_parallel === true || selfEntry.enabled === true)) + const minWorkers = typeof selfEntry?.min_workers === 'number' ? selfEntry.min_workers : 3 + const configuredMax = typeof selfEntry?.max_workers === 'number' ? selfEntry.max_workers : undefined + + const autoWorkers = computeWorkers(minWorkers) + const workers = configuredMax && configuredMax > 0 ? configuredMax : autoWorkers + + if (!enabled) { + logger.log(`[antora-sources-parallel] Loaded (disabled). Computed workers=${workers}. No changes applied.`) + return + } + + // Expose workers via env to encourage Antora and other extensions to leverage concurrency + // These envs are de-facto hints; if Antora honors them, great; otherwise harmless. + const envHints = [ + 'ANTORA_FETCH_CONCURRENCY', // potential fetch concurrency + 'ANTORA_CONCURRENCY', // generic task concurrency + 'ANTORA_SOURCES_PARALLEL_WORKERS', // our own explicit value for cooperating tools + ] + for (const key of envHints) { + if (!process.env[key]) process.env[key] = String(workers) + } + + logger.log(`[antora-sources-parallel] Enabled with workers=${workers}. Concurrency env hints set: ${envHints.join(', ')}.`) + + // Experimental fan-out guidance + const experimental = selfEntry?.experimental_fanout === true + if (experimental) { + logger.warn('[antora-sources-parallel] Experimental fan-out enabled in playbook. To run per-source fan-out, execute: npm run antora-fanout (from docs-site). Default antora run remains single-process (env-hint mode only).') + } + } catch (e) { + logger.warn(`[antora-sources-parallel] Failed to initialize: ${e && e.message ? e.message : e}`) + } + }) +} diff --git a/docs-site/extensions/antora-sources-parallel/package.json b/docs-site/extensions/antora-sources-parallel/package.json new file mode 100644 index 0000000000..f26383a4c2 --- /dev/null +++ b/docs-site/extensions/antora-sources-parallel/package.json @@ -0,0 +1,23 @@ +{ + "name": "antora-sources-parallel", + "version": "0.1.0", + "description": "Antora extension to enable parallel-friendly builds via concurrency hints and future per-source fan-out.", + "main": "index.js", + "license": "Apache-2.0", + "keywords": [ + "antora", + "extension", + "parallel", + "concurrency", + "docs" + ], + "bin": { + "antora-fanout": "bin/fanout.js" + }, + "dependencies": { + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">=14" + } +} From e57b1bd544f1ed39c991005c8b3b0b5ccb7915d1 Mon Sep 17 00:00:00 2001 From: swinston Date: Tue, 2 Sep 2025 12:55:13 -0700 Subject: [PATCH 05/12] Add `antora:ci` script and optimize CI caching for parallel builds Enhanced the Antora parallel sources extension with a fast path for CI builds (`--no-lunr`) and updated documentation with performance tips. Improved CI workflows with better caching strategies for Antora artifacts to boost efficiency. --- .github/workflows/ci.yml | 21 +++++++++++++---- .../antora-sources-parallel/README.md | 23 +++++++++++++++++++ .../antora-sources-parallel/bin/fanout.js | 15 +++++++++++- .../antora-sources-parallel/index.js | 8 ++++++- docs-site/package.json | 3 ++- 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da92674e53..9e6a36b095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,10 +113,21 @@ jobs: with: node-version: '20.12.2' cache: 'npm' - cache-dependency-path: docs-site/package-lock.json + cache-dependency-path: | + docs-site/package-lock.json + antora-ui-khronos/package-lock.json + Vulkan-Docs/package-lock.json - - name: "force clear the npm cache" - run: npm cache clean --force + - name: "Prepare Antora cache dir" + run: mkdir -p docs-site/build/.cache + + - name: "Cache Antora workdir" + uses: actions/cache@v4 + with: + path: docs-site/build/.cache + key: antora-cache-${{ hashFiles('docs-site/antora-playbook.yml', 'docs-site/package-lock.json') }} + restore-keys: | + antora-cache- - name: "run npm install for ui bundle" working-directory: antora-ui-khronos @@ -167,10 +178,10 @@ jobs: working-directory: Vulkan-Samples run: cmake -H"." -B"build/unix" -DVKB_GENERATE_ANTORA_SITE=ON - - name: "build (npx) with stacktrace" + - name: "build site in parallel (fan-out with Lunr)" working-directory: docs-site run: | - npx antora antora-playbook.yml --stacktrace + npm run antora-fanout touch build/site/.nojekyll - name: 'Upload site artifact' diff --git a/docs-site/extensions/antora-sources-parallel/README.md b/docs-site/extensions/antora-sources-parallel/README.md index 62336ee278..115bfc6394 100644 --- a/docs-site/extensions/antora-sources-parallel/README.md +++ b/docs-site/extensions/antora-sources-parallel/README.md @@ -67,3 +67,26 @@ Tuning: Notes: - The standard build (npx antora antora-playbook.yml) remains unchanged; fan-out is opt-in - Collisions in generated files are resolved "last writer wins" during merge + +## CI usage and performance tips + +- Fast path in CI: run the parallel fan-out without Lunr indexing. + - From docs-site/: npm run antora:ci + - Equivalent: node ./extensions/antora-sources-parallel/bin/fanout.js antora-playbook.yml --no-lunr + - You can also set ANTORA_NO_LUNR=1 instead of passing --no-lunr. + +- Concurrency tuning: + - ANTORA_SOURCES_PARALLEL_WORKERS: hard limit for shard workers. + - ANTORA_CONCURRENCY / ANTORA_FETCH_CONCURRENCY: generic hints also used by some tools. + - Default worker count is max(cpu cores, 3). + +- Caching: + - The extension sets ANTORA_CACHE_DIR to build/.cache if not already set. + - Configure your CI to cache the docs-site/build/.cache directory between runs to reduce repeated work. + +- Release builds (with search index): + - Use npm run antora-fanout to keep Lunr enabled while still running per-source in parallel. + - Or run the standard Antora command if you prefer the traditional single-process path. + +- No Makefile changes required: + - Keep Makefile as-is; point CI to run the npm script from docs-site instead. diff --git a/docs-site/extensions/antora-sources-parallel/bin/fanout.js b/docs-site/extensions/antora-sources-parallel/bin/fanout.js index ce948fb06d..0785d84452 100644 --- a/docs-site/extensions/antora-sources-parallel/bin/fanout.js +++ b/docs-site/extensions/antora-sources-parallel/bin/fanout.js @@ -96,9 +96,16 @@ async function mergeDirs (srcDir, dstDir) { async function main () { const docsSiteDir = process.cwd() - const playbookArg = process.argv[2] || 'antora-playbook.yml' + const argv = process.argv.slice(2) + const playbookArg = argv[0] || 'antora-playbook.yml' + const disableLunr = argv.includes('--no-lunr') || process.env.ANTORA_NO_LUNR === '1' const playbookPath = path.resolve(docsSiteDir, playbookArg) + // Encourage cache reuse if not already configured + if (!process.env.ANTORA_CACHE_DIR) { + process.env.ANTORA_CACHE_DIR = path.join(docsSiteDir, 'build', '.cache') + } + const playbook = await readYaml(playbookPath) const sources = playbook?.content?.sources if (!Array.isArray(sources) || sources.length === 0) { @@ -120,6 +127,12 @@ async function main () { for (const { src, idx } of tasks) { const pb = JSON.parse(JSON.stringify(playbook)) pb.content.sources = [src] + + // Optionally remove lunr extension to speed up CI builds + if (disableLunr && pb.antora && Array.isArray(pb.antora.extensions)) { + pb.antora.extensions = pb.antora.extensions.filter((e) => !(e && e.require === '@antora/lunr-extension')) + } + // Ensure ui bundle reference remains relative and output writes to unique dir pb.output = pb.output || {} pb.output.dir = path.join('build', '.fanout', String(idx)) diff --git a/docs-site/extensions/antora-sources-parallel/index.js b/docs-site/extensions/antora-sources-parallel/index.js index 9aa06b3911..b0b9c72467 100644 --- a/docs-site/extensions/antora-sources-parallel/index.js +++ b/docs-site/extensions/antora-sources-parallel/index.js @@ -46,7 +46,13 @@ module.exports = function (registry) { if (!process.env[key]) process.env[key] = String(workers) } - logger.log(`[antora-sources-parallel] Enabled with workers=${workers}. Concurrency env hints set: ${envHints.join(', ')}.`) + // Encourage Antora cache reuse in CI/local unless already specified + if (!process.env.ANTORA_CACHE_DIR) { + // Use a project-local cache directory so CI can persist it as an artifact between runs + process.env.ANTORA_CACHE_DIR = require('path').join(process.cwd(), 'build', '.cache') + } + + logger.log(`[antora-sources-parallel] Enabled with workers=${workers}. Concurrency env hints set: ${envHints.join(', ')}. Cache dir: ${process.env.ANTORA_CACHE_DIR}`) // Experimental fan-out guidance const experimental = selfEntry?.experimental_fanout === true diff --git a/docs-site/package.json b/docs-site/package.json index 6f3f6620db..46f2d774fe 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -12,6 +12,7 @@ }, "scripts": { "npx": "npx", - "antora-fanout": "node ./extensions/antora-sources-parallel/bin/fanout.js antora-playbook.yml" + "antora-fanout": "node ./extensions/antora-sources-parallel/bin/fanout.js antora-playbook.yml", + "antora:ci": "node ./extensions/antora-sources-parallel/bin/fanout.js antora-playbook.yml --no-lunr" } } From 1f29702b1f9203b450e12493632f9ffec70524f1 Mon Sep 17 00:00:00 2001 From: swinston Date: Tue, 2 Sep 2025 13:25:56 -0700 Subject: [PATCH 06/12] Enhance `antora-sources-parallel` with path rebasing, Lunr optimization, and global index build Added utilities to rebase relative playbook paths for proper resolution. Improved shard builds by removing Lunr extension per shard and introducing a single global Lunr index pass. Ensured global search-index artifacts are correctly copied to the final site. --- .../antora-sources-parallel/bin/fanout.js | 100 +++++++++++++++++- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/docs-site/extensions/antora-sources-parallel/bin/fanout.js b/docs-site/extensions/antora-sources-parallel/bin/fanout.js index 0785d84452..9a3069610e 100644 --- a/docs-site/extensions/antora-sources-parallel/bin/fanout.js +++ b/docs-site/extensions/antora-sources-parallel/bin/fanout.js @@ -94,6 +94,46 @@ async function mergeDirs (srcDir, dstDir) { } } +function isLikelyUrl (val) { + return typeof val === 'string' && /^(https?:)?\/\//i.test(val) +} + +function isRelativePath (val) { + return typeof val === 'string' && (val.startsWith('./') || val.startsWith('../')) +} + +function rebasePlaybookPaths (pb, rootDir) { + if (!pb || !rootDir) return pb + // Rebase AsciiDoc extensions that are relative paths + if (pb.asciidoc && Array.isArray(pb.asciidoc.extensions)) { + pb.asciidoc.extensions = pb.asciidoc.extensions.map((ext) => { + if (typeof ext === 'string' && isRelativePath(ext)) { + return path.resolve(rootDir, ext) + } else if (ext && typeof ext === 'object' && isRelativePath(ext.require)) { + return { ...ext, require: path.resolve(rootDir, ext.require) } + } + return ext + }) + } + // Rebase Antora extensions whose require is a relative path + if (pb.antora && Array.isArray(pb.antora.extensions)) { + pb.antora.extensions = pb.antora.extensions.map((e) => { + if (e && typeof e === 'object' && typeof e.require === 'string' && isRelativePath(e.require)) { + return { ...e, require: path.resolve(rootDir, e.require) } + } + return e + }) + } + // Rebase UI bundle local file URL + if (pb.ui && pb.ui.bundle && typeof pb.ui.bundle.url === 'string') { + const u = pb.ui.bundle.url + if (!isLikelyUrl(u) && !path.isAbsolute(u)) { + pb.ui.bundle.url = path.resolve(rootDir, u) + } + } + return pb +} + async function main () { const docsSiteDir = process.cwd() const argv = process.argv.slice(2) @@ -125,15 +165,18 @@ async function main () { // Create temp playbooks with single source and per-run output dir const tempPlaybooks = [] for (const { src, idx } of tasks) { - const pb = JSON.parse(JSON.stringify(playbook)) + let pb = JSON.parse(JSON.stringify(playbook)) pb.content.sources = [src] - // Optionally remove lunr extension to speed up CI builds - if (disableLunr && pb.antora && Array.isArray(pb.antora.extensions)) { + // Remove lunr extension for shard builds to avoid per-shard indexing; we'll build it once globally later (unless disabled) + if (pb.antora && Array.isArray(pb.antora.extensions)) { pb.antora.extensions = pb.antora.extensions.filter((e) => !(e && e.require === '@antora/lunr-extension')) } - // Ensure ui bundle reference remains relative and output writes to unique dir + // Rebase any relative paths to absolute paths from docs-site root so antora resolves them correctly + pb = rebasePlaybookPaths(pb, docsSiteDir) + + // Ensure output writes to unique dir per shard pb.output = pb.output || {} pb.output.dir = path.join('build', '.fanout', String(idx)) @@ -187,6 +230,55 @@ async function main () { console.log(`[fanout] Completed per-source build for ${tasks.length} sources with workers=${workers}`) console.log(`[fanout] Output merged to: ${finalOut}`) + + // If Lunr is not explicitly disabled, do one additional pass to build a global search index only + if (!disableLunr) { + const indexOut = path.join(docsSiteDir, 'build', '.fanout', 'index') + // Prepare a playbook for the index pass: keep all sources and ensure lunr extension is present + let indexPb = JSON.parse(JSON.stringify(playbook)) + // Ensure Lunr extension is included + if (indexPb.antora && Array.isArray(indexPb.antora.extensions)) { + const hasLunr = indexPb.antora.extensions.some((e) => e && e.require === '@antora/lunr-extension') + if (!hasLunr) indexPb.antora.extensions.unshift({ require: '@antora/lunr-extension', index_latest_only: true }) + } + // Rebase paths for the index pass and direct output to temp index dir + indexPb = rebasePlaybookPaths(indexPb, docsSiteDir) + indexPb.output = indexPb.output || {} + indexPb.output.dir = path.relative(docsSiteDir, indexOut) + + const indexPlaybookFile = path.join(fanoutRoot, 'playbook-index.yml') + await writeYaml(indexPlaybookFile, indexPb) + + console.log('[fanout] Building global Lunr index (single pass)...') + try { + await runAntora(docsSiteDir, indexPlaybookFile) + } catch (e) { + console.error('[fanout] Global Lunr index build failed:', e && e.message ? e.message : e) + process.exitCode = 1 + return + } + + // Copy search-index artifacts from indexOut into finalOut + try { + const entries = await fsp.readdir(indexOut) + const indexFiles = entries.filter((name) => /^search-index\..*/.test(name)) + for (const name of indexFiles) { + const src = path.join(indexOut, name) + const dst = path.join(finalOut, name) + await mkdirp(path.dirname(dst)) + await fsp.copyFile(src, dst) + } + if (indexFiles.length) { + console.log(`[fanout] Copied global Lunr artifacts to final site: ${indexFiles.join(', ')}`) + } else { + console.warn('[fanout] No search-index.* artifacts found to copy. Verify lunr extension configuration.') + } + } catch (e) { + console.warn('[fanout] Failed to copy Lunr artifacts:', e && e.message ? e.message : e) + } + } else { + console.log('[fanout] Lunr disabled (--no-lunr). Skipping global index pass.') + } } main().catch((e) => { From ea8fab3210fefc85c827b33a3c93d7473b589df4 Mon Sep 17 00:00:00 2001 From: swinston Date: Tue, 2 Sep 2025 14:14:56 -0700 Subject: [PATCH 07/12] Expand `antora-sources-parallel` to shard sources by `start_paths` for finer-grained parallelism. --- .../antora-sources-parallel/bin/fanout.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs-site/extensions/antora-sources-parallel/bin/fanout.js b/docs-site/extensions/antora-sources-parallel/bin/fanout.js index 9a3069610e..de55e1597d 100644 --- a/docs-site/extensions/antora-sources-parallel/bin/fanout.js +++ b/docs-site/extensions/antora-sources-parallel/bin/fanout.js @@ -152,6 +152,21 @@ async function main () { throw new Error('No content.sources found in playbook') } + // Expand any sources that specify start_paths into separate shards per path + const expandedSources = [] + for (const s of sources) { + if (s && Array.isArray(s.start_paths) && s.start_paths.length > 0) { + for (const sp of s.start_paths) { + const ns = { ...s } + delete ns.start_paths + ns.start_path = sp + expandedSources.push(ns) + } + } else { + expandedSources.push(s) + } + } + const workers = getWorkersFromEnv() const fanoutRoot = path.join(docsSiteDir, 'build', '.fanout') const finalOut = path.join(docsSiteDir, 'build', 'site') @@ -160,7 +175,7 @@ async function main () { await mkdirp(fanoutRoot) // Prepare per-source playbooks - const tasks = sources.map((src, idx) => ({ src, idx })) + const tasks = expandedSources.map((src, idx) => ({ src, idx })) // Create temp playbooks with single source and per-run output dir const tempPlaybooks = [] From a227eff8f36ca482837bc895e9657c23a3859519 Mon Sep 17 00:00:00 2001 From: swinston Date: Tue, 2 Sep 2025 14:44:25 -0700 Subject: [PATCH 08/12] Add configurable Antora cache directory and optimize Lunr indexing Introduced the ability to set a custom `ANTORA_CACHE_DIR` and ensured directories are created as needed. Improved Lunr global index builds with a fast-path option and fallback to legacy Antora mode for robustness. Enhanced logging for cache usage and indexing steps. --- .../antora-sources-parallel/bin/fanout.js | 126 ++++++++++++------ 1 file changed, 84 insertions(+), 42 deletions(-) diff --git a/docs-site/extensions/antora-sources-parallel/bin/fanout.js b/docs-site/extensions/antora-sources-parallel/bin/fanout.js index de55e1597d..3de9e99287 100644 --- a/docs-site/extensions/antora-sources-parallel/bin/fanout.js +++ b/docs-site/extensions/antora-sources-parallel/bin/fanout.js @@ -59,7 +59,12 @@ async function mkdirp (p) { function runAntora (cwd, playbookPath) { return new Promise((resolve, reject) => { - const child = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['antora', playbookPath, '--stacktrace'], { + const cacheDir = process.env.ANTORA_CACHE_DIR + const args = ['antora', playbookPath, '--stacktrace'] + if (cacheDir) { + args.push('--cache-dir', cacheDir) + } + const child = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx', args, { cwd, stdio: 'inherit', env: process.env, @@ -145,6 +150,8 @@ async function main () { if (!process.env.ANTORA_CACHE_DIR) { process.env.ANTORA_CACHE_DIR = path.join(docsSiteDir, 'build', '.cache') } + await mkdirp(process.env.ANTORA_CACHE_DIR) + console.log(`[fanout] Using Antora cache dir: ${process.env.ANTORA_CACHE_DIR}`) const playbook = await readYaml(playbookPath) const sources = playbook?.content?.sources @@ -246,50 +253,85 @@ async function main () { console.log(`[fanout] Completed per-source build for ${tasks.length} sources with workers=${workers}`) console.log(`[fanout] Output merged to: ${finalOut}`) - // If Lunr is not explicitly disabled, do one additional pass to build a global search index only + // If Lunr is not explicitly disabled, build a global search index if (!disableLunr) { - const indexOut = path.join(docsSiteDir, 'build', '.fanout', 'index') - // Prepare a playbook for the index pass: keep all sources and ensure lunr extension is present - let indexPb = JSON.parse(JSON.stringify(playbook)) - // Ensure Lunr extension is included - if (indexPb.antora && Array.isArray(indexPb.antora.extensions)) { - const hasLunr = indexPb.antora.extensions.some((e) => e && e.require === '@antora/lunr-extension') - if (!hasLunr) indexPb.antora.extensions.unshift({ require: '@antora/lunr-extension', index_latest_only: true }) - } - // Rebase paths for the index pass and direct output to temp index dir - indexPb = rebasePlaybookPaths(indexPb, docsSiteDir) - indexPb.output = indexPb.output || {} - indexPb.output.dir = path.relative(docsSiteDir, indexOut) - - const indexPlaybookFile = path.join(fanoutRoot, 'playbook-index.yml') - await writeYaml(indexPlaybookFile, indexPb) - - console.log('[fanout] Building global Lunr index (single pass)...') - try { - await runAntora(docsSiteDir, indexPlaybookFile) - } catch (e) { - console.error('[fanout] Global Lunr index build failed:', e && e.message ? e.message : e) - process.exitCode = 1 - return - } - - // Copy search-index artifacts from indexOut into finalOut - try { - const entries = await fsp.readdir(indexOut) - const indexFiles = entries.filter((name) => /^search-index\..*/.test(name)) - for (const name of indexFiles) { - const src = path.join(indexOut, name) - const dst = path.join(finalOut, name) - await mkdirp(path.dirname(dst)) - await fsp.copyFile(src, dst) + const mode = (process.env.ANTORA_LUNR_MODE || 'fast').toLowerCase() + if (mode === 'antora') { + // Legacy path: run Antora lunr extension once + const indexOut = path.join(docsSiteDir, 'build', '.fanout', 'index') + let indexPb = JSON.parse(JSON.stringify(playbook)) + if (indexPb.antora && Array.isArray(indexPb.antora.extensions)) { + const hasLunr = indexPb.antora.extensions.some((e) => e && e.require === '@antora/lunr-extension') + if (!hasLunr) indexPb.antora.extensions.unshift({ require: '@antora/lunr-extension', index_latest_only: true }) } - if (indexFiles.length) { - console.log(`[fanout] Copied global Lunr artifacts to final site: ${indexFiles.join(', ')}`) - } else { - console.warn('[fanout] No search-index.* artifacts found to copy. Verify lunr extension configuration.') + indexPb = rebasePlaybookPaths(indexPb, docsSiteDir) + indexPb.output = indexPb.output || {} + indexPb.output.dir = path.relative(docsSiteDir, indexOut) + const indexPlaybookFile = path.join(fanoutRoot, 'playbook-index.yml') + await writeYaml(indexPlaybookFile, indexPb) + console.log('[fanout] Building global Lunr index with Antora (legacy)...') + try { + await runAntora(docsSiteDir, indexPlaybookFile) + // Copy search-index artifacts from indexOut into finalOut + const entries = await fsp.readdir(indexOut) + const indexFiles = entries.filter((name) => /^search-index\..*/.test(name)) + for (const name of indexFiles) { + const src = path.join(indexOut, name) + const dst = path.join(finalOut, name) + await mkdirp(path.dirname(dst)) + await fsp.copyFile(src, dst) + } + if (indexFiles.length) { + console.log(`[fanout] Copied global Lunr artifacts to final site: ${indexFiles.join(', ')}`) + } else { + console.warn('[fanout] No search-index.* artifacts found to copy. Verify lunr extension configuration.') + } + } catch (e) { + console.error('[fanout] Global Lunr index build failed (Antora):', e && e.message ? e.message : e) + process.exitCode = 1 + return + } + } else { + // Fast path: build directly from merged site + const indexer = path.join(__dirname, 'build-lunr-from-site.js') + console.log('[fanout] Building global Lunr index from merged site (fast path)...') + try { + // invoke Node child process to avoid leaking state + await new Promise((resolve, reject) => { + const child = require('child_process').spawn(process.execPath, [indexer, path.join(docsSiteDir, 'build', 'site')], { + cwd: docsSiteDir, + stdio: 'inherit', + env: process.env, + }) + child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`indexer exited with code ${code}`)))) + child.on('error', reject) + }) + } catch (e) { + console.warn('[fanout] Fast Lunr indexer failed; falling back to Antora:', e && e.message ? e.message : e) + process.env.ANTORA_LUNR_MODE = 'antora' + return await (async () => { // recurse into legacy path + const indexOut = path.join(docsSiteDir, 'build', '.fanout', 'index') + let indexPb = JSON.parse(JSON.stringify(playbook)) + if (indexPb.antora && Array.isArray(indexPb.antora.extensions)) { + const hasLunr = indexPb.antora.extensions.some((e) => e && e.require === '@antora/lunr-extension') + if (!hasLunr) indexPb.antora.extensions.unshift({ require: '@antora/lunr-extension', index_latest_only: true }) + } + indexPb = rebasePlaybookPaths(indexPb, docsSiteDir) + indexPb.output = indexPb.output || {} + indexPb.output.dir = path.relative(docsSiteDir, indexOut) + const indexPlaybookFile = path.join(fanoutRoot, 'playbook-index.yml') + await writeYaml(indexPlaybookFile, indexPb) + await runAntora(docsSiteDir, indexPlaybookFile) + const entries = await fsp.readdir(indexOut) + const indexFiles = entries.filter((name) => /^search-index\..*/.test(name)) + for (const name of indexFiles) { + const src = path.join(indexOut, name) + const dst = path.join(finalOut, name) + await mkdirp(path.dirname(dst)) + await fsp.copyFile(src, dst) + } + })() } - } catch (e) { - console.warn('[fanout] Failed to copy Lunr artifacts:', e && e.message ? e.message : e) } } else { console.log('[fanout] Lunr disabled (--no-lunr). Skipping global index pass.') From 8175048ff5d54d58aa79a622f4406351e44f5450 Mon Sep 17 00:00:00 2001 From: swinston Date: Tue, 2 Sep 2025 15:04:02 -0700 Subject: [PATCH 09/12] Add global Lunr index builder to `antora-sources-parallel` extension Introduced a new script `build-lunr-from-site.js` to generate a global search index directly from the merged Antora site. Updated `package.json` to include the `lunr` dependency required for indexing. --- .../bin/build-lunr-from-site.js | 159 ++++++++++++++++++ .../antora-sources-parallel/package.json | 3 +- 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js diff --git a/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js b/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js new file mode 100644 index 0000000000..b9b5051e9d --- /dev/null +++ b/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/* + * Fast Lunr indexer: builds a global search index directly from the merged Antora site HTML. + * + * Usage: + * node build-lunr-from-site.js + * + * Output: + * /search-index.json + * /search-index.js (assigns window.searchIndex = { ... }) + */ + +const fs = require('fs') +const fsp = fs.promises +const path = require('path') +const os = require('os') +const lunr = require('lunr') + +function cpuWorkers (min = 3) { + const cores = Array.isArray(os.cpus()) ? os.cpus().length : 1 + return Math.max(min, cores || 1) +} + +function getWorkers () { + const envKeys = ['ANTORA_SOURCES_PARALLEL_WORKERS', 'ANTORA_CONCURRENCY', 'ANTORA_FETCH_CONCURRENCY'] + for (const k of envKeys) { + const v = process.env[k] + if (v && +v > 0) return +v + } + return cpuWorkers(3) +} + +function stripHtml (html) { + // Remove script/style contents + html = html.replace(//gi, ' ').replace(//gi, ' ') + // Replace tags with spaces + html = html.replace(/<[^>]+>/g, ' ') + // Decode a few common entities + html = html.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + // Collapse whitespace + return html.replace(/\s+/g, ' ').trim() +} + +async function readFileSafe (file) { + try { + return await fsp.readFile(file, 'utf8') + } catch (e) { + return '' + } +} + +function extractTitle (html, fallback) { + const h1 = html.match(/]*>([\s\S]*?)<\/h1>/i) + if (h1 && h1[1]) return stripHtml(h1[1]) + const title = html.match(/]*>([\s\S]*?)<\/title>/i) + if (title && title[1]) return stripHtml(title[1]) + return fallback || '' +} + +async function listHtmlFiles (dir) { + const out = [] + async function walk (d) { + const entries = await fsp.readdir(d, { withFileTypes: true }) + for (const ent of entries) { + const p = path.join(d, ent.name) + if (ent.isDirectory()) { + // skip some known non-page dirs if present + if (ent.name === '_') continue + await walk(p) + } else if (ent.isFile()) { + if (p.endsWith('.html')) out.push(p) + } + } + } + await walk(dir) + return out +} + +async function withPool (items, limit, worker) { + const results = new Array(items.length) + let i = 0 + let inFlight = 0 + let rejectFn + return await new Promise((resolve, reject) => { + rejectFn = reject + const next = () => { + while (inFlight < limit && i < items.length) { + const idx = i++ + inFlight++ + Promise.resolve(worker(items[idx], idx)) + .then((r) => { + results[idx] = r + inFlight-- + if (i >= items.length && inFlight === 0) resolve(results) + else next() + }) + .catch((e) => rejectFn(e)) + } + if (i >= items.length && inFlight === 0) resolve(results) + } + next() + }) +} + +function toSiteUrl (siteDir, filePath) { + // Convert absolute file path back to site-relative URL + const rel = path.relative(siteDir, filePath) + let url = rel.replace(/\\/g, '/') + // Ensure it starts with a slash for UI expectations + if (!url.startsWith('/')) url = '/' + url + return url +} + +async function buildIndex (siteDir) { + const files = await listHtmlFiles(siteDir) + const workers = getWorkers() + const docs = [] + + await withPool(files, workers, async (file) => { + const html = await readFileSafe(file) + if (!html) return + const url = toSiteUrl(siteDir, file) + const title = extractTitle(html, path.basename(file, '.html')) + const text = stripHtml(html) + docs.push({ id: url, title, url, text }) + }) + + // Build Lunr index + const idx = lunr(function () { + this.ref('id') + this.field('title', { boost: 10 }) + this.field('text') + for (const d of docs) this.add(d) + }) + + return { docs: docs.map(({ id, title, url }) => ({ id, title, url })), index: idx.toJSON() } +} + +async function writeOutputs (siteDir, payload) { + const jsonPath = path.join(siteDir, 'search-index.json') + const jsPath = path.join(siteDir, 'search-index.js') + const json = JSON.stringify(payload) + await fsp.writeFile(jsonPath, json, 'utf8') + const js = `window.searchIndex=${json};\n` + await fsp.writeFile(jsPath, js, 'utf8') +} + +async function main () { + const siteDir = path.resolve(process.argv[2] || path.join(process.cwd(), 'build', 'site')) + console.log(`[lunr-fast] Indexing site at: ${siteDir}`) + const payload = await buildIndex(siteDir) + await writeOutputs(siteDir, payload) + console.log('[lunr-fast] Wrote search-index.json and search-index.js') +} + +main().catch((e) => { + console.error('[lunr-fast] fatal:', e && e.message ? e.message : e) + process.exit(1) +}) diff --git a/docs-site/extensions/antora-sources-parallel/package.json b/docs-site/extensions/antora-sources-parallel/package.json index f26383a4c2..3c094be4d2 100644 --- a/docs-site/extensions/antora-sources-parallel/package.json +++ b/docs-site/extensions/antora-sources-parallel/package.json @@ -15,7 +15,8 @@ "antora-fanout": "bin/fanout.js" }, "dependencies": { - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "lunr": "^2.3.9" }, "engines": { "node": ">=14" From 2b0ec3a7ae42d929c0f20384d718ffb273794ab7 Mon Sep 17 00:00:00 2001 From: swinston Date: Tue, 2 Sep 2025 15:27:12 -0700 Subject: [PATCH 10/12] Optimize Lunr indexing with conservative IO concurrency and memory limits Refactored Lunr builder to use minimal memory by limiting IO concurrency through `ANTORA_LUNR_IO_WORKERS` (default: 2) and capping characters per page (`ANTORA_LUNR_MAX_CHARS`, default: 200,000). Replaced custom parallel pool logic with a streamlined approach and added elapsed time logging for indexing. --- .../bin/build-lunr-from-site.js | 101 ++++++++++-------- .../antora-sources-parallel/bin/fanout.js | 5 +- 2 files changed, 58 insertions(+), 48 deletions(-) diff --git a/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js b/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js index b9b5051e9d..bda295e74e 100644 --- a/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js +++ b/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js @@ -22,12 +22,20 @@ function cpuWorkers (min = 3) { } function getWorkers () { - const envKeys = ['ANTORA_SOURCES_PARALLEL_WORKERS', 'ANTORA_CONCURRENCY', 'ANTORA_FETCH_CONCURRENCY'] + const envKeys = ['ANTORA_LUNR_IO_WORKERS', 'ANTORA_SOURCES_PARALLEL_WORKERS', 'ANTORA_CONCURRENCY', 'ANTORA_FETCH_CONCURRENCY'] for (const k of envKeys) { const v = process.env[k] if (v && +v > 0) return +v } - return cpuWorkers(3) + // Default to conservative IO concurrency to minimize memory pressure + return Math.min(cpuWorkers(3), 2) +} + +function getMaxCharsPerPage () { + const v = process.env.ANTORA_LUNR_MAX_CHARS + if (v && +v > 0) return +v + // Reasonable upper bound to avoid pathological pages blowing memory + return 200000 } function stripHtml (html) { @@ -76,32 +84,6 @@ async function listHtmlFiles (dir) { return out } -async function withPool (items, limit, worker) { - const results = new Array(items.length) - let i = 0 - let inFlight = 0 - let rejectFn - return await new Promise((resolve, reject) => { - rejectFn = reject - const next = () => { - while (inFlight < limit && i < items.length) { - const idx = i++ - inFlight++ - Promise.resolve(worker(items[idx], idx)) - .then((r) => { - results[idx] = r - inFlight-- - if (i >= items.length && inFlight === 0) resolve(results) - else next() - }) - .catch((e) => rejectFn(e)) - } - if (i >= items.length && inFlight === 0) resolve(results) - } - next() - }) -} - function toSiteUrl (siteDir, filePath) { // Convert absolute file path back to site-relative URL const rel = path.relative(siteDir, filePath) @@ -113,27 +95,50 @@ function toSiteUrl (siteDir, filePath) { async function buildIndex (siteDir) { const files = await listHtmlFiles(siteDir) - const workers = getWorkers() - const docs = [] - - await withPool(files, workers, async (file) => { - const html = await readFileSafe(file) - if (!html) return - const url = toSiteUrl(siteDir, file) - const title = extractTitle(html, path.basename(file, '.html')) - const text = stripHtml(html) - docs.push({ id: url, title, url, text }) - }) + const ioWorkers = getWorkers() + const maxChars = getMaxCharsPerPage() + + // Minimal doc metadata to ship with the index + const docMeta = [] + + // Prepare a Lunr builder so we can add docs incrementally (low memory) + const builder = new lunr.Builder() + builder.ref('id') + builder.field('title', { boost: 10 }) + builder.field('text') - // Build Lunr index - const idx = lunr(function () { - this.ref('id') - this.field('title', { boost: 10 }) - this.field('text') - for (const d of docs) this.add(d) + // Process files in small concurrent batches for IO, but add to index immediately + let idxNext = 0 + let inFlight = 0 + await new Promise((resolve, reject) => { + const next = () => { + while (inFlight < ioWorkers && idxNext < files.length) { + const file = files[idxNext++] + inFlight++ + ;(async () => { + const html = await readFileSafe(file) + const url = toSiteUrl(siteDir, file) + const title = extractTitle(html, path.basename(file, '.html')) + let text = stripHtml(html) + if (maxChars && text.length > maxChars) text = text.slice(0, maxChars) + // Add to index and discard text immediately + builder.add({ id: url, title, text }) + docMeta.push({ id: url, title, url }) + })() + .then(() => { + inFlight-- + if (idxNext >= files.length && inFlight === 0) resolve() + else next() + }) + .catch((e) => reject(e)) + } + if (idxNext >= files.length && inFlight === 0) resolve() + } + next() }) - return { docs: docs.map(({ id, title, url }) => ({ id, title, url })), index: idx.toJSON() } + const index = builder.build() + return { docs: docMeta, index: index.toJSON() } } async function writeOutputs (siteDir, payload) { @@ -148,9 +153,11 @@ async function writeOutputs (siteDir, payload) { async function main () { const siteDir = path.resolve(process.argv[2] || path.join(process.cwd(), 'build', 'site')) console.log(`[lunr-fast] Indexing site at: ${siteDir}`) + const t0 = Date.now() const payload = await buildIndex(siteDir) await writeOutputs(siteDir, payload) - console.log('[lunr-fast] Wrote search-index.json and search-index.js') + const dt = ((Date.now() - t0) / 1000).toFixed(2) + console.log(`[lunr-fast] Wrote search-index.json and search-index.js in ${dt}s`) } main().catch((e) => { diff --git a/docs-site/extensions/antora-sources-parallel/bin/fanout.js b/docs-site/extensions/antora-sources-parallel/bin/fanout.js index 3de9e99287..f297e62a9b 100644 --- a/docs-site/extensions/antora-sources-parallel/bin/fanout.js +++ b/docs-site/extensions/antora-sources-parallel/bin/fanout.js @@ -298,10 +298,13 @@ async function main () { try { // invoke Node child process to avoid leaking state await new Promise((resolve, reject) => { + const env = { ...process.env } + // Use conservative IO concurrency by default to reduce memory pressure + if (!env.ANTORA_LUNR_IO_WORKERS) env.ANTORA_LUNR_IO_WORKERS = '2' const child = require('child_process').spawn(process.execPath, [indexer, path.join(docsSiteDir, 'build', 'site')], { cwd: docsSiteDir, stdio: 'inherit', - env: process.env, + env, }) child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`indexer exited with code ${code}`)))) child.on('error', reject) From df476b8356abe5df0ac1e46c74c2213c08843593 Mon Sep 17 00:00:00 2001 From: swinston Date: Tue, 2 Sep 2025 15:45:07 -0700 Subject: [PATCH 11/12] Further reduce memory and CPU usage during Lunr indexing Set stricter defaults for IO concurrency (`ANTORA_LUNR_IO_WORKERS=1`) and max characters per page (`ANTORA_LUNR_MAX_CHARS=60,000`). Added a simplified pipeline (`ANTORA_LUNR_SIMPLE_PIPELINE=1`) to minimize resource use by skipping heavy stemming and stopword filters. Restricted indexing to `latest` pages when applicable. --- .../bin/build-lunr-from-site.js | 27 +++++++++++++++---- .../antora-sources-parallel/bin/fanout.js | 5 ++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js b/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js index bda295e74e..8faf94c2c8 100644 --- a/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js +++ b/docs-site/extensions/antora-sources-parallel/bin/build-lunr-from-site.js @@ -27,15 +27,15 @@ function getWorkers () { const v = process.env[k] if (v && +v > 0) return +v } - // Default to conservative IO concurrency to minimize memory pressure - return Math.min(cpuWorkers(3), 2) + // Default to ultra-conservative IO concurrency to minimize memory pressure + return 1 } function getMaxCharsPerPage () { const v = process.env.ANTORA_LUNR_MAX_CHARS if (v && +v > 0) return +v - // Reasonable upper bound to avoid pathological pages blowing memory - return 200000 + // Tighter upper bound to avoid pathological pages blowing memory + return 60000 } function stripHtml (html) { @@ -94,7 +94,12 @@ function toSiteUrl (siteDir, filePath) { } async function buildIndex (siteDir) { - const files = await listHtmlFiles(siteDir) + let files = await listHtmlFiles(siteDir) + // Index latest-only by default if such pages exist + const hasLatest = files.some((f) => f.includes(`${path.sep}latest${path.sep}`) || f.includes('/latest/')) + if (hasLatest) { + files = files.filter((f) => f.includes(`${path.sep}latest${path.sep}`) || f.includes('/latest/')) + } const ioWorkers = getWorkers() const maxChars = getMaxCharsPerPage() @@ -107,6 +112,18 @@ async function buildIndex (siteDir) { builder.field('title', { boost: 10 }) builder.field('text') + // Optional simplified pipeline to reduce memory/CPU + const simplePipeline = (process.env.ANTORA_LUNR_SIMPLE_PIPELINE || '1') === '1' + if (simplePipeline) { + // Remove heavy stemming/stopword filters; keep minimal trimmer + builder.pipeline.reset() + builder.searchPipeline.reset() + if (lunr.trimmer) { + builder.pipeline.add(lunr.trimmer) + builder.searchPipeline.add(lunr.trimmer) + } + } + // Process files in small concurrent batches for IO, but add to index immediately let idxNext = 0 let inFlight = 0 diff --git a/docs-site/extensions/antora-sources-parallel/bin/fanout.js b/docs-site/extensions/antora-sources-parallel/bin/fanout.js index f297e62a9b..d52f213b83 100644 --- a/docs-site/extensions/antora-sources-parallel/bin/fanout.js +++ b/docs-site/extensions/antora-sources-parallel/bin/fanout.js @@ -299,8 +299,9 @@ async function main () { // invoke Node child process to avoid leaking state await new Promise((resolve, reject) => { const env = { ...process.env } - // Use conservative IO concurrency by default to reduce memory pressure - if (!env.ANTORA_LUNR_IO_WORKERS) env.ANTORA_LUNR_IO_WORKERS = '2' + // Use ultra-conservative defaults to reduce memory pressure; can be overridden via env + if (!env.ANTORA_LUNR_IO_WORKERS) env.ANTORA_LUNR_IO_WORKERS = '1' + if (!env.ANTORA_LUNR_SIMPLE_PIPELINE) env.ANTORA_LUNR_SIMPLE_PIPELINE = '1' const child = require('child_process').spawn(process.execPath, [indexer, path.join(docsSiteDir, 'build', 'site')], { cwd: docsSiteDir, stdio: 'inherit', From 0d76b41b1a5c2e0769f56d75b0c0ff64fb09cdca Mon Sep 17 00:00:00 2001 From: swinston Date: Tue, 2 Sep 2025 16:07:48 -0700 Subject: [PATCH 12/12] Increase V8 heap for Lunr indexing during CI builds Added `ANTORA_LUNR_NODE_MAX_OLD_SPACE` to allow configurable heap size (`--max-old-space-size`) for Lunr index serialization. Updated CI workflow and `fanout.js` to set conservative default values for memory and pipeline options. --- .github/workflows/ci.yml | 4 ++++ docs-site/extensions/antora-sources-parallel/bin/fanout.js | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e6a36b095..d8809914a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,6 +180,10 @@ jobs: - name: "build site in parallel (fan-out with Lunr)" working-directory: docs-site + env: + ANTORA_LUNR_NODE_MAX_OLD_SPACE: '6144' + ANTORA_LUNR_IO_WORKERS: '1' + ANTORA_LUNR_SIMPLE_PIPELINE: '1' run: | npm run antora-fanout touch build/site/.nojekyll diff --git a/docs-site/extensions/antora-sources-parallel/bin/fanout.js b/docs-site/extensions/antora-sources-parallel/bin/fanout.js index d52f213b83..394becec1c 100644 --- a/docs-site/extensions/antora-sources-parallel/bin/fanout.js +++ b/docs-site/extensions/antora-sources-parallel/bin/fanout.js @@ -302,6 +302,10 @@ async function main () { // Use ultra-conservative defaults to reduce memory pressure; can be overridden via env if (!env.ANTORA_LUNR_IO_WORKERS) env.ANTORA_LUNR_IO_WORKERS = '1' if (!env.ANTORA_LUNR_SIMPLE_PIPELINE) env.ANTORA_LUNR_SIMPLE_PIPELINE = '1' + // Allow increasing V8 heap for large index serialization + const maxOldSpace = env.ANTORA_LUNR_NODE_MAX_OLD_SPACE || '6144' + const existingNodeOptions = env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ` : '' + env.NODE_OPTIONS = `${existingNodeOptions}--max-old-space-size=${maxOldSpace}`.trim() const child = require('child_process').spawn(process.execPath, [indexer, path.join(docsSiteDir, 'build', 'site')], { cwd: docsSiteDir, stdio: 'inherit',