Skip to content

feat: add --workers flag for concurrent scenario execution#4616

Merged
cidrblock merged 6 commits intomainfrom
multi_worker
Feb 25, 2026
Merged

feat: add --workers flag for concurrent scenario execution#4616
cidrblock merged 6 commits intomainfrom
multi_worker

Conversation

@cidrblock
Copy link
Copy Markdown

Summary

Adds --workers N to molecule test, check, and destroy for running collection scenarios concurrently using concurrent.futures.ProcessPoolExecutor. The existing sequential code path is completely untouched when --workers is absent or set to 1.

Design

The parallel path runs only in collection mode with shared_state. The main process handles the default scenario's create and destroy lifecycle serially. All non-default scenarios are submitted to a process pool and execute their prepare → converge → idempotence → verify → cleanup sequence in isolated workers. Results are collected via futures.

Failure modes:

  • fail-fast (default): on first failure, executor.shutdown(cancel_futures=True) is called, remaining workers are cancelled, destroy runs, and collected failures are reported.
  • --continue-on-failure: all scenarios run to completion, failures are buffered and displayed after destroy.

Ansible output is suppressed in workers by default (Molecule INFO logs still stream freely). On failure, the captured stdout+stderr is displayed in bordered blocks after the default scenario's destroy phase completes. Pass -v to stream all ansible output from workers.

File-by-file changes

Core runtime (minimal changes, no behavioral change for existing users)

src/molecule/command/base.py — 8 lines added. _run_scenarios gains an early return: if workers > 1, it lazy-imports and delegates to run_scenarios_parallel. The entire existing sequential path is untouched; it only runs when workers <= 1 (the default).

src/molecule/app.py — 4 lines changed. run_command checks MOLECULE_QUIET_ANSIBLE env var. When set, tee is disabled (ansible stdout is captured but not streamed) and CommandBorders initialization is skipped. This env var is only set by worker processes; normal runs are unaffected.

src/molecule/exceptions.py — 3 lines added. ScenarioFailureError.__init__ accepts an optional ansible_output string to carry captured ansible stdout+stderr from the failing step. Fully backward-compatible; defaults to "".

src/molecule/provisioner/ansible_playbook.py — 2 lines added. On non-zero ansible exit, the combined result.stdout + result.stderr is passed as ansible_output to ScenarioFailureError. No change to the raise itself or any other behavior.

src/molecule/state.py — 4 lines changed. _get_data now merges loaded state on top of defaults instead of returning loaded state directly. Handles the case where a state file written by an older molecule version is missing keys that newer code expects. Defensive fix; no impact on current sequential runs.

src/molecule/types.py — 6 lines added. CommandArgs TypedDict extended with workers: int and continue_on_failure: bool. Non-breaking since total=False.

New module

src/molecule/worker.py — 278 lines (new). Contains:

  • run_one_scenario(): reconstructs a Config from picklable args in a worker process, runs execute_scenario with shared_state=True, returns a 4-tuple of (ScenarioResults, error_msg, ansible_output, failed_step).
  • run_scenarios_parallel(): orchestrates the full lifecycle — default create, prerun, pool dispatch, result collection, destroy, failure reporting.
  • validate_worker_args(): ensures --workers > 1 is only used in collection mode and not with --destroy=never.
  • _print_failed_output(): renders bordered failure blocks to stderr after destroy.

Forces prepare=True on every worker Config to avoid the shared-state "already prepared" flag race condition. Comment in code explains why this is necessary vs sequential mode.

CLI plumbing

src/molecule/click_cfg.py — 56 lines added. New workers and continue_on_failure CLI options. New resolve_workers() function that parses "cpus", "cpus-1", or an integer string.

src/molecule/command/test.py, check.py, destroy.py — Each adds --workers and --continue-on-failure to its option list, calls resolve_workers(), emits a DeprecationWarning if --parallel is used.

Output formatting

src/molecule/ansi_output.py — 3 new module-level functions extracted from CommandBorders: create_border_header(), create_border_footer(), write_bordered_block(). Used by _print_failed_output in worker.py to render failure output. Existing CommandBorders class is untouched.

Documentation

docs/guides/parallel.md — Updated to document --workers, --continue-on-failure, and the deprecation of --parallel.

Configuration

pyproject.toml — 1 line: added R0401 (cyclic-import) to pylint's global disable list. The base.py → worker.py lazy import is intentional and unavoidable.

.config/dictionary.txt — cspell dictionary update.

Tests

tests/unit/test_worker.py — 430 lines (new). Unit tests for validate_worker_args, run_one_scenario, and run_scenarios_parallel. Uses monkeypatch/mocker.patch (no @patch decorators, no test classes). Covers success, failure, fail-fast, continue-on-failure, and quiet-ansible env var behavior.

tests/unit/test_click_cfg.py — 118 lines added. Tests for resolve_workers: integers, cpus, cpus-1, case insensitivity, None fallback, invalid input, zero, negative values.

tests/integration/test_workers.py — 168 lines (new). 4 end-to-end tests against a static fixture collection (tests/fixtures/integration/test_workers/): parallel success with output suppression and report, verbose mode ansible streaming, continue-on-failure with grouped failure output, and fail-fast with cpus-1 resolution.

tests/fixtures/integration/test_workers/ — Static ansible-native collection with a default scenario (create/destroy) and 6 lightweight test scenarios using ansible.builtin.debug tasks.

Test plan

  • tox -e lint — all 22 pre-commit checks pass (ruff, pylint, mypy, pydoclint, cspell, etc.)
  • tox -e py — unit and integration tests pass
  • Sequential execution (--workers 1 or omitted) is completely unaffected
  • CI passes on this PR

Made with Cursor

cidrblock and others added 5 commits February 24, 2026 12:00
Introduce a new `--workers N` CLI option (supports integer, `cpus`,
`cpus-1`) that runs collection scenarios concurrently using a
ProcessPoolExecutor. The default scenario's create/destroy lifecycle
runs serially in the main process while worker processes handle
individual scenario test sequences.

Key changes:
- New `src/molecule/worker.py` with `run_one_scenario` and
  `run_scenarios_parallel` functions
- `--continue-on-failure` flag to run all scenarios even after failures
  (default is fail-fast)
- `--parallel` flag marked deprecated in help text with runtime warning
- State file loading made resilient to incomplete/stale state data
- Worker processes explicitly set MOLECULE_PROJECT_DIRECTORY and chdir
  to handle Python 3.14's forkserver multiprocessing start method
- Updated docs/guides/parallel.md with new --workers documentation
- Comprehensive unit tests (tests/unit/test_worker.py) and integration
  tests (tests/integration/test_workers.py) with ansible-native
  collection generator

Co-authored-by: Cursor <[email protected]>
With shared_state, all scenarios share a single state file. The first
scenario's prepare sets prepared=True, causing all subsequent scenarios
to skip their prepare playbook ("Skipping, instances already prepared").
This is wrong when each scenario has its own prepare playbook that seeds
different test data.

Fix by injecting force=True into worker command_args so Prepare.execute()
bypasses the shared "prepared" flag. Integration tests now assert that
each scenario's prepare actually executes in both serial and parallel
modes via unique STEP_PREPARE_SCENARIO markers in playbook output.

Co-authored-by: Cursor <[email protected]>
The unit tests for run_one_scenario set MOLECULE_PROJECT_DIRECTORY in
os.environ (via the function under test) but didn't clean it up. This
leaked '/path/to' into integration test subprocesses, causing workers
to crash with "No such file or directory: '/path/to'".

Fix by wrapping unit tests with @patch.dict(os.environ) to isolate
env changes, and stripping MOLECULE_PROJECT_DIRECTORY from the
integration test subprocess environment as a safety net.

Co-authored-by: Cursor <[email protected]>
- Add failure output formatting with bordered blocks showing
  "Failed: scenario > step" after destroy phase completes
- Suppress verbose ansible output in workers by default
  (MOLECULE_QUIET_ANSIBLE), show only on -v
- Capture ansible stdout/stderr on failure via ScenarioFailureError
- Refactor CommandBorders into reusable module-level functions
- Rewrite unit tests: remove classes, use monkeypatch/mocker
  instead of @patch decorators, extract shared helpers
- Flatten integration tests: remove class, use static fixtures
  from tests/fixtures/integration/test_workers/
- Consolidate 9 integration tests into 4 for faster execution
- Fix all lint issues (ruff, pylint, pydoclint, mypy, cspell)
- Add R0401 cyclic-import to pylint global disable list

Co-authored-by: Cursor <[email protected]>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 introduces concurrent scenario execution for Ansible collections via the --workers N flag for molecule test, check, and destroy commands. The implementation uses Python's ProcessPoolExecutor to run non-default scenarios in parallel while the default scenario's infrastructure lifecycle (create/destroy) remains serial. The existing sequential code path is completely preserved when --workers is 1 or omitted.

Changes:

  • Added --workers and --continue-on-failure CLI options with support for integer values, cpus, or cpus-1 for worker count resolution
  • Implemented parallel execution in a new worker.py module with fail-fast and continue-on-failure modes
  • Extended error handling to capture and display Ansible output from failed worker scenarios

Reviewed changes

Copilot reviewed 43 out of 49 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/molecule/worker.py New module implementing parallel scenario execution with process pool management
src/molecule/command/base.py Added early return in _run_scenarios to delegate to parallel path when workers > 1
src/molecule/click_cfg.py Added workers and continue_on_failure CLI options plus resolve_workers() function
src/molecule/command/test.py Integrated new CLI options and deprecation warning for --parallel
src/molecule/command/check.py Integrated new CLI options and deprecation warning for --parallel
src/molecule/command/destroy.py Integrated new CLI options and deprecation warning for --parallel
src/molecule/types.py Extended CommandArgs TypedDict with workers and continue_on_failure fields
src/molecule/state.py Fixed state loading to merge with defaults for backward compatibility
src/molecule/exceptions.py Added ansible_output parameter to ScenarioFailureError
src/molecule/provisioner/ansible_playbook.py Captures ansible stdout+stderr and passes to exception
src/molecule/app.py Added MOLECULE_QUIET_ANSIBLE env var handling to suppress worker output
src/molecule/ansi_output.py Extracted border rendering functions for reuse in worker failure output
pyproject.toml Disabled R0401 pylint rule for intentional lazy imports
docs/guides/parallel.md Updated documentation for --workers and deprecated --parallel
tests/unit/test_worker.py Unit tests for worker validation, execution, and parallel orchestration
tests/unit/test_click_cfg.py Unit tests for resolve_workers() and new CLI options
tests/integration/test_workers.py End-to-end integration tests with static fixture collection
tests/fixtures/integration/test_workers/* Static test collection with 6 scenarios for integration testing
Comments suppressed due to low confidence (3)

tests/unit/test_worker.py:1

  • The assertion call_count >= 2 is weak and may pass even if the behavior is incorrect. Consider asserting the exact expected call count (2 in this case) or verifying that both specific results were appended. This would make the test more precise and less prone to false positives.
    tests/integration/test_workers.py:1
  • Using a hardcoded /tmp/ path in test fixtures can lead to security issues and conflicts in shared environments. Consider using tempfile.mkdtemp() or a test-specific temporary directory to ensure isolation and avoid predictable paths.
    src/molecule/click_cfg.py:1
  • This test is checking implementation details (the presence of 'EXPERIMENTAL:' prefix) rather than the actual behavior of the experimental flag. Consider testing that the flag is correctly set to True on the option object instead of parsing generated help text.
"""New Click Configuration System with CliOption Architecture.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@cidrblock cidrblock merged commit a41aa11 into main Feb 25, 2026
26 of 27 checks passed
@cidrblock cidrblock deleted the multi_worker branch February 25, 2026 18:52
rija added a commit to rmenage/ansible-role-hardened-docker that referenced this pull request Mar 12, 2026
This MR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [molecule](https://github.com/ansible-community/molecule) ([changelog](https://github.com/ansible-community/molecule/releases)) | `>=26.2.0,<26.3.0` → `>=26.3.0,<26.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/molecule/26.3.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/molecule/26.2.0/26.3.0?slim=true) |

---

### Release Notes

<details>
<summary>ansible-community/molecule (molecule)</summary>

### [`v26.3.0`](https://github.com/ansible/molecule/releases/tag/v26.3.0)

[Compare Source](ansible/molecule@v26.2.0...v26.3.0)

#### Features

- feat: add --workers flag for concurrent scenario execution ([#&#8203;4616](ansible/molecule#4616)) [@&#8203;cidrblock](https://github.com/cidrblock)

#### Maintenance

- chore(deps): update pep621 ([#&#8203;4619](ansible/molecule#4619)) @&#8203;[renovate\[bot\]](https://github.com/apps/renovate)
- chore(deps): update all dependencies ([#&#8203;4618](ansible/molecule#4618)) @&#8203;[renovate\[bot\]](https://github.com/apps/renovate)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever MR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this MR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this MR, check this box

---

This MR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi45OS4wIiwidXBkYXRlZEluVmVyIjoiNDIuOTkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->


Refs:  rmenage/ansible-role-hardened-docker!27
See-also:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

3 participants