You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Molecule can run a collection's scenarios in parallel with --workers. When those scenarios also share state — shared_state: true, which the parallel-testing guide recommends — every scenario running at the same time reads and writes the same small state file, and molecule does not protect that file against simultaneous access. The parallel runs collide on it, and two things go wrong:
A run crashes. One scenario reads the state file just as another is partway through rewriting it, gets a half-written file, and molecule aborts with a YAML parsing error. This is the intermittent test_workers failure on the py314-milestone CI job.
A run silently loses data. Each scenario rewrites the whole file from its own in-memory copy, so one scenario can overwrite a value another just wrote — no error, just a lost update.
This is not specific to Python 3.14; that version's timing exposes the crash more often, but the cause is the same everywhere.
Underneath, the state file is written non-atomically (it is truncated, then rewritten) and updated by reading the whole file, changing one field, and writing it all back — safe for a single process, unsafe for several at once. The exact code paths, and small scripts that reproduce both problems deterministically, are below.
Scope
The parallel-testing guide (docs/guides/parallel.md:30-32) tells maintainers to turn on shared_state: true when using --workers, and --workers is the recommended replacement for the deprecated --parallel. So the corruption happens on the configuration the docs point people to. It has to be turned on deliberately — --workers defaults to 1 — but that is exactly the step the guide describes for running a collection's scenarios in parallel.
Recommendation (phased)
1 — Immediate: make the state write atomic. Write to a temp file in the same directory and os.replace() onto state.yml, so a reader always sees a complete old-or-new document. This removes defect 1 (verified below: 104/9000 corrupt reads → 0/9000, no other change) and is safe to ship now — os.replace is atomic on POSIX and Windows, it changes only how the file is written (not its schema or semantics), and it can be scoped to the state write alone. It does not fix defect 2; it is a first step, not the design fix.
2 — The real fix: set up shared infrastructure once, then give each worker its own state. Molecule already runs the shared create and destroy once, centrally, for a parallel run. What's left racing is prepare (forced to run in every worker) and the per-scenario flags being written to the one shared file at the same time. The fix I'd suggest: stand the shared infrastructure up once before the parallel run begins, and let each worker keep its own state file for the phases that actually run in parallel (converge, idempotence, side_effect, verify). Then nothing is shared and written at the same time, and both problems go away. One real design decision sits inside this — who runs prepare — which is why I'm raising it here rather than sending a patch (see below).
3 — Long-term: per-group shared infrastructure. The original RFE also envisioned grouping scenarios that share infrastructure. That half was never built (today shared_state is a project-wide boolean). It's a larger RFE with real design questions, and it overlaps an open PR that adds --slice; noted here only so the short-term fix leaves room for it, not to scope it in.
How this came to be (feature history)
RFE: Shared state between scenarios #4001 (RFE: Shared state between scenarios, closed) — the original request. It frames the goal as sharing infrastructure so scenarios can be split up without re-provisioning, and it already sketches the intended pattern for using shared state in parallel — provision once through a single scenario:
Care will need to be taken by the user if running scenarios in parallel that share state as a race condition might occur during provisioning. The documented approach might suggest something like running molecule prepare prior to running parallel scenarios, effectively using the default scenario as the provisioner.
That provision-once pattern is the direction of the step-2 fix below.
feat: add --workers flag for concurrent scenario execution #4616 (add --workers for concurrent scenario execution, merged) — runs scenarios in parallel via a ProcessPoolExecutor. This is where the shared state.yml starts being read and written concurrently.
Reproducible in-tree: the test_workers integration suite runs molecule test --all --workers 2 against a fixture whose base config sets shared_state: true (tests/fixtures/integration/test_workers/extensions/molecule/config.yml:13, inherited by all six otherwise-empty scenario molecule.yml files). So molecule's own CI exercises exactly the shared_state + --workers combination.
Environment: molecule 26.6.1.dev9, Python 3.14.6 (the py314-milestone interpreter; py3.14 timing makes the race hit more often, but it is not py3.14-specific).
Both scripts drive molecule's own functions — no mocks — and are deterministic on a normal multi-core machine.
The script runs several processes that concurrently util.write_file(state.yml, safe_dump(...)) / util.safe_load_file(state.yml) on one shared path — exactly the pair State._write_state_file / State._load_file use.
ok x8896
TRUNCATED_DICT(missing keys) x102
MoleculeError: while scanning a quoted scalar x2
CORRUPTION: 104 corrupt read(s)
104 of 9000 reads saw a partial document, including a genuine yaml.scanner.ScannerError — the same failure class as the CI could not find expected ':' (the exact scanner message varies with where the truncation lands).
With the atomic write applied (temp file + os.replace, described below), the identical run reports ok x9000 — 0 corrupt reads.
Defect 2 — silent lost update (script + transcript)
This models State's real write path: load the shared file once into an in-memory snapshot, mutate one key, write the whole snapshot back — never re-reading. Two workers start from the same seed and each set a different flag; the writes are deliberately serialized so there is no tearing at all.
importmultiprocessingasmp, tempfile, timefrompathlibimportPathfrommoleculeimportutildefworker(path, key, barrier, delay):
p=Path(path)
snap=util.safe_load_file(p) or {} # load once (as State.__init__ does)barrier.wait() # both snapshot the same seedtime.sleep(delay) # force a write order; no torn writesnap[key] =True# mutate one key in memoryutil.write_file(p, util.safe_dump(snap)) # write the WHOLE snapshot backdefmain():
withtempfile.TemporaryDirectory() asd:
path=str(Path(d) /"state.yml")
util.write_file(Path(path), util.safe_dump(
{"created": False, "converged": False, "run_uuid": "seed"}))
ctx=mp.get_context("fork"); b=ctx.Barrier(2)
a=ctx.Process(target=worker, args=(path, "created", b, 0.0))
c=ctx.Process(target=worker, args=(path, "converged", b, 0.2))
a.start(); c.start(); a.join(); c.join()
final=util.safe_load_file(Path(path))
print(f"final: created={final['created']} converged={final['converged']}")
return1ifnotfinal["created"] else0if__name__=="__main__": raiseSystemExit(main())
final: created=False converged=True
Worker A set created=True and wrote first; worker B set converged=True and wrote second. No write was torn — B's whole-snapshot write, taken before A's write existed, simply overwrote created back to False.
Defect 1 — non-atomic write
Write path: State._write_state_file (state.py:234) → util.write_file → Path.write_text(content) (util.py:222). This truncates the file, then writes — a window in which a concurrent reader sees an empty or partial document.
Read path: State._load_file (state.py:231) → util.safe_load_file → util.safe_load, which catches yaml.scanner.ScannerError and re-raises it as MoleculeError (util.py:285-286).
Defect 2 — whole-file overwrite from a stale snapshot
State loads the file into memory once in __init__ (state.py:114); every mutation goes through change_state → @marshal (state.py:81-83, 194-209), which sets one key in memory and dumps the entire in-memory dict back. A worker never re-reads before writing, so two workers each writing their own snapshot lose each other's changes.
In the current worker flows this is largely latent: prepare is force-run (so the cross-process prepared read is skipped), create/destroy are skipped in workers, and converged is written and read within one process. Today the lost update mostly surfaces as a molecule_yml_date_modified mismatch warning rather than a functional failure. It is still a correctness bug on the shared code path, which is why the atomic write (defect 1 only) is a first step and not the whole fix.
Why the atomic write is safe to ship on its own
Atomicity is guaranteed on the same filesystem. The temp file is created in the state file's own directory, so os.replace is a same-filesystem rename — atomic on POSIX (rename(2)) and Windows. A reader opening the path gets either the old complete file or the new complete file, never a truncated one.
Nothing else changes. The bytes written are identical (safe_dump output with the existing header); only the write mechanism changes. Readers, schema, and call sites are untouched.
It can be scoped narrowly — to the state write (State._write_state_file, or a small util.atomic_write_file used only there) rather than to every write_file caller — keeping the blast radius to the file that has the race.
Measured: the defect-1 repro goes from 104/9000 corrupt reads to 0/9000 with only this change.
Caveat: if a process is killed between creating the temp file and the rename, a stray temp file can be left in the directory. Using a recognizable prefix and unlinking on error limits that to a leftover temp file.
The design fix, and the one question I'd like maintainer input on
Beyond the crash, the durable fix is to stop concurrently mutating one shared state file under --workers. Per-scenario state already exists and is the default (State._get_state_file → scenario.ephemeral_directory/state.yml); SharedState is the opt-in that collapses every scenario onto one file. Provisioning is already delegated and run once centrally in the parallel path (create/destroy bracket the pool). So the shape I'd propose: provision once up front, then let each worker use its own per-scenario state.yml for the phases that run in parallel. No shared mutable state during the parallel window.
The decision that isn't mine to make is who runs prepare. prepare is overloaded: there is infra-level preparation of the shared instances (belongs with the single provisioner, run once) and per-scenario preparation of a scenario's own view (its own prepare.yml — a real, used feature). Today --workers sidesteps the distinction by force-running prepare everywhere. A correct fix has to separate the two: run the owner's infra prepare once, and still run each scenario's own prepare in its worker. I didn't want to encode one interpretation of that in a patch without maintainers weighing in — hence this issue rather than a PR for step 2.
The long-term picture (for context, not this issue)
The RFE's second use case — grouping scenarios that share infrastructure — was never implemented; shared_state is a project-wide boolean and the shared directory is keyed only on the project (scenario.py). Fully realizing it means a schema to associate a scenario with a named shared-infra group, keying the shared directory per group, and a per-group owner/lifecycle, with real design questions (group ownership of create/destroy, partial-group failure, interaction with --workers). It also overlaps an open PR adding --slice, which proposes another way to group scenarios for workers; the two would need to compose rather than duplicate. That's a separate, larger RFE — mentioned so the near-term fix does not preclude it.
Environment
molecule 26.6.1.dev9
Python 3.14.6
Reproduced deterministically via the scripts above (they drive molecule.util / molecule.state directly).
Summary
Molecule can run a collection's scenarios in parallel with
--workers. When those scenarios also share state —shared_state: true, which the parallel-testing guide recommends — every scenario running at the same time reads and writes the same small state file, and molecule does not protect that file against simultaneous access. The parallel runs collide on it, and two things go wrong:test_workersfailure on thepy314-milestoneCI job.This is not specific to Python 3.14; that version's timing exposes the crash more often, but the cause is the same everywhere.
Underneath, the state file is written non-atomically (it is truncated, then rewritten) and updated by reading the whole file, changing one field, and writing it all back — safe for a single process, unsafe for several at once. The exact code paths, and small scripts that reproduce both problems deterministically, are below.
Scope
The parallel-testing guide (
docs/guides/parallel.md:30-32) tells maintainers to turn onshared_state: truewhen using--workers, and--workersis the recommended replacement for the deprecated--parallel. So the corruption happens on the configuration the docs point people to. It has to be turned on deliberately —--workersdefaults to1— but that is exactly the step the guide describes for running a collection's scenarios in parallel.Recommendation (phased)
1 — Immediate: make the state write atomic. Write to a temp file in the same directory and
os.replace()ontostate.yml, so a reader always sees a complete old-or-new document. This removes defect 1 (verified below: 104/9000 corrupt reads → 0/9000, no other change) and is safe to ship now —os.replaceis atomic on POSIX and Windows, it changes only how the file is written (not its schema or semantics), and it can be scoped to the state write alone. It does not fix defect 2; it is a first step, not the design fix.2 — The real fix: set up shared infrastructure once, then give each worker its own state. Molecule already runs the shared
createanddestroyonce, centrally, for a parallel run. What's left racing isprepare(forced to run in every worker) and the per-scenario flags being written to the one shared file at the same time. The fix I'd suggest: stand the shared infrastructure up once before the parallel run begins, and let each worker keep its own state file for the phases that actually run in parallel (converge,idempotence,side_effect,verify). Then nothing is shared and written at the same time, and both problems go away. One real design decision sits inside this — who runsprepare— which is why I'm raising it here rather than sending a patch (see below).3 — Long-term: per-group shared infrastructure. The original RFE also envisioned grouping scenarios that share infrastructure. That half was never built (today
shared_stateis a project-wide boolean). It's a larger RFE with real design questions, and it overlaps an open PR that adds--slice; noted here only so the short-term fix leaves room for it, not to scope it in.How this came to be (feature history)
RFE: Shared state between scenarios #4001 (RFE: Shared state between scenarios, closed) — the original request. It frames the goal as sharing infrastructure so scenarios can be split up without re-provisioning, and it already sketches the intended pattern for using shared state in parallel — provision once through a single scenario:
That provision-once pattern is the direction of the step-2 fix below.
Add support for shared inventory between scenarios #4443 (Add support for shared inventory between scenarios, merged) — shared inventory, closes RFE: Shared inventory directory #4002.
Share state between molecule scenarios #4470 (Share state between molecule scenarios, merged) — adds
SharedStatebacked by the shared ephemeral directory; closes RFE: Shared state between scenarios #4001. Shipped as a single boolean (shared_state/--shared-state), project-wide.feat: add --workers flag for concurrent scenario execution #4616 (add
--workersfor concurrent scenario execution, merged) — runs scenarios in parallel via aProcessPoolExecutor. This is where the sharedstate.ymlstarts being read and written concurrently.feat: add --slice flag for worker scenario grouping #4617 (add
--slice, open) — related, relevant to the long-term grouping question below.Reproducible in-tree: the
test_workersintegration suite runsmolecule test --all --workers 2against a fixture whose base config setsshared_state: true(tests/fixtures/integration/test_workers/extensions/molecule/config.yml:13, inherited by all six otherwise-empty scenariomolecule.ymlfiles). So molecule's own CI exercises exactly theshared_state+--workerscombination.Related:
py314-milestoneflake was observed.Reproduction
Environment: molecule
26.6.1.dev9, Python3.14.6(thepy314-milestoneinterpreter; py3.14 timing makes the race hit more often, but it is not py3.14-specific).Both scripts drive molecule's own functions — no mocks — and are deterministic on a normal multi-core machine.
Defect 1 — partial-read crash (script + transcript)
The script runs several processes that concurrently
util.write_file(state.yml, safe_dump(...))/util.safe_load_file(state.yml)on one shared path — exactly the pairState._write_state_file/State._load_fileuse.104 of 9000 reads saw a partial document, including a genuine
yaml.scanner.ScannerError— the same failure class as the CIcould not find expected ':'(the exact scanner message varies with where the truncation lands).With the atomic write applied (temp file +
os.replace, described below), the identical run reportsok x9000— 0 corrupt reads.Defect 2 — silent lost update (script + transcript)
This models
State's real write path: load the shared file once into an in-memory snapshot, mutate one key, write the whole snapshot back — never re-reading. Two workers start from the same seed and each set a different flag; the writes are deliberately serialized so there is no tearing at all.Worker A set
created=Trueand wrote first; worker B setconverged=Trueand wrote second. No write was torn — B's whole-snapshot write, taken before A's write existed, simply overwrotecreatedback toFalse.Defect 1 — non-atomic write
State._write_state_file(state.py:234) →util.write_file→Path.write_text(content)(util.py:222). This truncates the file, then writes — a window in which a concurrent reader sees an empty or partial document.State._load_file(state.py:231) →util.safe_load_file→util.safe_load, which catchesyaml.scanner.ScannerErrorand re-raises it asMoleculeError(util.py:285-286).Defect 2 — whole-file overwrite from a stale snapshot
Stateloads the file into memory once in__init__(state.py:114); every mutation goes throughchange_state→@marshal(state.py:81-83,194-209), which sets one key in memory and dumps the entire in-memory dict back. A worker never re-reads before writing, so two workers each writing their own snapshot lose each other's changes.In the current worker flows this is largely latent:
prepareis force-run (so the cross-processpreparedread is skipped),create/destroyare skipped in workers, andconvergedis written and read within one process. Today the lost update mostly surfaces as amolecule_yml_date_modifiedmismatch warning rather than a functional failure. It is still a correctness bug on the shared code path, which is why the atomic write (defect 1 only) is a first step and not the whole fix.Why the atomic write is safe to ship on its own
os.replaceis a same-filesystem rename — atomic on POSIX (rename(2)) and Windows. A reader opening the path gets either the old complete file or the new complete file, never a truncated one.safe_dumpoutput with the existing header); only the write mechanism changes. Readers, schema, and call sites are untouched.State._write_state_file, or a smallutil.atomic_write_fileused only there) rather than to everywrite_filecaller — keeping the blast radius to the file that has the race.Caveat: if a process is killed between creating the temp file and the rename, a stray temp file can be left in the directory. Using a recognizable prefix and unlinking on error limits that to a leftover temp file.
The design fix, and the one question I'd like maintainer input on
Beyond the crash, the durable fix is to stop concurrently mutating one shared state file under
--workers. Per-scenario state already exists and is the default (State._get_state_file→scenario.ephemeral_directory/state.yml);SharedStateis the opt-in that collapses every scenario onto one file. Provisioning is already delegated and run once centrally in the parallel path (create/destroybracket the pool). So the shape I'd propose: provision once up front, then let each worker use its own per-scenariostate.ymlfor the phases that run in parallel. No shared mutable state during the parallel window.The decision that isn't mine to make is who runs
prepare.prepareis overloaded: there is infra-level preparation of the shared instances (belongs with the single provisioner, run once) and per-scenario preparation of a scenario's own view (its ownprepare.yml— a real, used feature). Today--workerssidesteps the distinction by force-runningprepareeverywhere. A correct fix has to separate the two: run the owner's infraprepareonce, and still run each scenario's ownpreparein its worker. I didn't want to encode one interpretation of that in a patch without maintainers weighing in — hence this issue rather than a PR for step 2.The long-term picture (for context, not this issue)
The RFE's second use case — grouping scenarios that share infrastructure — was never implemented;
shared_stateis a project-wide boolean and the shared directory is keyed only on the project (scenario.py). Fully realizing it means a schema to associate a scenario with a named shared-infra group, keying the shared directory per group, and a per-group owner/lifecycle, with real design questions (group ownership of create/destroy, partial-group failure, interaction with--workers). It also overlaps an open PR adding--slice, which proposes another way to group scenarios for workers; the two would need to compose rather than duplicate. That's a separate, larger RFE — mentioned so the near-term fix does not preclude it.Environment
26.6.1.dev93.14.6molecule.util/molecule.statedirectly).