Writing scenarios
A scenario is a plain Python file with a main(args) function. stormweaver <scenario.py> [-c config.toml] [-i /pg/install/dir] loads the file and calls main(args) with the parsed CLI namespace (args.config, args.install_dir, plus any scenario-specific options). A scenario declares its own options with an optional add_arguments(parser) function: stormweaver imports the scenario, folds those options into its own argument parser, and only then parses the command line - so stormweaver <scenario.py> --help lists the scenario's options too, and a mistyped flag is rejected instead of silently ignored.
Almost every scenario should start from stormweaver.scenario: scenario.add_common_arguments()/scenario.finalize() for the common CLI options, and scenario.single_pg()/scenario.single_mysql() for a ready-to-run server + workload. This page documents that framework. For raw access to the underlying pieces (sw.Postgres, sw.Workload, sw.Worker, ...), see Randomized testing concepts; the framework is built directly on top of them.
Running a scenario
-c/--config- TOML config file, defaultconfig/stormweaver.toml(see Config parameters)-i/--install-dir- database installation directory; falls back topgrootin the config file-v/--verbose/-q/--quiet- console verbosity (debug / warnings only)--log-mode/--log-splits- log layout, see Unified logging- everything else is scenario-specific, declared by the scenario's
add_arguments(see below)
A run creates, relative to the current working directory: datadirs/ (one subdirectory per server, via Config.datadir()) and logs/<timestamp>-<scenario name>/. In the default split mode that run directory holds the main log plus per-server, per-worker and per-connection SQL logs; in unified mode everything goes into a single main.log instead. Backup-testing scenarios additionally create backups/ and archive/ (see below).
Caveat: don't run from a deeply nested CWD. PostgreSQL puts its Unix domain socket in the data directory (unix_socket_directories points at datadirs/datadir_<name>), and the kernel's socket path limit is about 107 bytes. A long enough working directory path pushes the socket path over that limit and pg_ctl start fails with no obviously-related error. Run from somewhere shallow (e.g. the repo root).
Unified logging
Unified mode routes everything - scenario messages, server lifecycle, per-connection SQL statements - into one chronological main.log. Enable it with any of (highest precedence first): the --log-mode unified flag, STORMWEAVER_LOG_MODE=unified in the environment, or a LOG_MODE = "unified" module-level attribute in the scenario file. To also keep the split per-connection/worker files, add --log-splits, STORMWEAVER_LOG_SPLITS=1, or LOG_SPLITS = True. -q only quiets the console: the unified main.log always keeps full INFO detail.
Notable moments appear as structured event lines of the shape KIND key=value key="quoted value". The kinds are RUN (run header: scenario, argv, cwd, STORMWEAVER_* env), STEP (script phase begin/end), ASSERT (a check, status=pass/status=fail with expected/actual fields), NODE (server lifecycle: init/start/stop/kill/promote), DUMP (multi-line context such as a traceback or server log tail - the block lines that follow are prefixed with |), and OUTCOME (final result). Mark your own script phases with stormweaver.events.step:
add_arguments / scenario.add_common_arguments / scenario.finalize
Options are declared in add_arguments(parser) (module-level, called by the CLI before parsing) and consumed in main(args) (called with the already-parsed namespace):
def add_arguments(parser):
scenario.add_common_arguments(parser)
def main(args):
opts = scenario.finalize(args)
...
scenario.add_common_arguments(parser) registers the options every scenario shares:
| Option | Default | Meaning |
|---|---|---|
--duration |
10 | seconds per workload cycle |
--workers |
5 | concurrent workers per cycle |
--repeat |
5 | number of cycles the scenario should run (the scenario's own loop, single_pg/single_mysql don't loop for you) |
--tde |
off |
on (per-database keys), on_wal (global keys + WAL encryption), or off |
--pgsm |
off |
preload pg_stat_monitor |
--clear-logs |
off | delete old logs/ subdirectories (never the current run's) |
scenario.finalize(args) does the post-parse work: resolves args.config (Config.load(args.config)) and args.install_dir (args.install_dir or config.pgroot, raises if neither is set), builds the wrapper object, and clears old logs if asked. It returns the same namespace, ready to hand to single_pg/single_mysql.
Add scenario-specific flags in add_arguments with parser.add_argument(...), or override a common default with parser.set_defaults(...):
def add_arguments(parser):
scenario.add_common_arguments(parser)
parser.set_defaults(duration=30, workers=4, repeat=2)
scenarios/demo/basic.py uses set_defaults to change the default tde value; scenarios/demo/test_pg_rewind_tde.py shows a scenario that declares its own --mode/--cipher/--keyring and skips the common options entirely. A scenario with no options (e.g. scenarios/ci/determinism.py) omits add_arguments and just reads args.config/args.install_dir directly.
single_pg / single_mysql
Sets up a fresh single server: wipes and recreates its data directory, starts it with sane defaults (log_min_messages, max_connections, shared_buffers, and shared_preload_libraries for --tde/--pgsm), waits for it to be ready, creates the test database, builds an action registry with the framework's standard actions (checkpoint, vacuum_full_table, truncate_table, reindex_table; partition actions removed - see STANDARD_ACTIONS in scenario.py), seeds it with initial tables, and returns a context with a workload ready to run. The server is stopped on exit even if the with block raises.
single_pg(opts, *, archive=False, extra_config=None, conn_settings=None, db_setup=None, initial_tables=5, dbname="testdb", worker_setup=None, datadir_name="primary"):
archive- turn on WAL archiving/summarization (archive_mode,archive_commandintoarchive/,summarize_wal) and createarchive/; needed forpg_basebackup/PITR-style scenariosextra_config- dict merged into the server config, applied after the framework's own settings (can override them)conn_settings- callable(conn) -> None, run on every connectionctx.connect()opens, after the TDE access-methodSETdb_setup- callable(worker) -> None, replaces the defaultworker.create_random_tables(initial_tables)seeding stepinitial_tables- table count for the default seeding (ignored ifdb_setupis given)dbname- test database nameworker_setup- callable(worker, index) -> None, forwarded toWorkload(customize a specific worker's action registry; seescenarios/demo/basic.py)datadir_name- suffix forConfig.datadir(), change it if a scenario runs more than one primary
If opts.tde != "off", single_pg also creates a keyring file next to the data directory and runs init_tde_only_for_db/init_tde_globally before seeding tables. Either way, single_pg restarts the server once after seeding (TDE init may use ALTER SYSTEM, which needs a restart to take effect) - the context is only handed to the caller after that restart succeeds.
single_mysql(opts, *, extra_config=None, db_setup=None, initial_tables=5, dbname="testdb", worker_setup=None, datadir_name="primary_mysql") - same shape, no archive/TDE (not supported for MySQL yet).
Both are context managers yielding a ScenarioContext (PgContext/MySqlContext) with:
ctx.db- the backend instance (Postgres/MySQL);ctx.pg/ctx.myis the same object under a backend-specific namectx.dbname,ctx.datadir- the test database name and the server's data directory (as a string)ctx.keyring- keyring path (PgContextonly,Noneif TDE is off)ctx.metadata- the sharedsw.Metadata()for this serverctx.registry- the action registryctx.workloadwas built fromctx.workload- a readysw.Workload(--workers/--durationfromopts); one cycle per.run()/.start()+.wait()call - scenarios own the--repeatloop themselves (see the example below)..run()blocks for the cycle;.start()/.wait()split that in two, with.workersexposing the live workers in between - use that to mutate a specific worker's action registry mid-run (seescenarios/demo/basic.py)..print_report()/.worker_statistics()cover the last.run()call (each.run()resets them); with the.start()/.wait()pattern they accumulate across cycles.ctx.connect(log_name="scenario")- open a fresh connection the same way the workload does (TDE access method set,conn_settingsapplied)ctx.make_worker(name)- a one-offsw.Workerwith a unique name (for setup/verification/one-shot SQL outside the workload)ctx.restart_and_wait(timeout=10)-ctx.db.restart(timeout)thenwait_ready(), raises if the server doesn't come backctx.validate_metadata_or_warn()- runsWorker.validate_metadata(), logs a warning instead of failing (metadata can legitimately diverge under concurrent DDL - see Determinism)
Helpers
scenario.fresh_dir(*paths)- delete and recreate each path (warns if it existed); use for scenario-owned directories likebackups//archive/before reusing them across runsscenario.wait_for_log(path, pattern, timeout, offset=0)- poll a log file for a substring,True/Falseon timeout;offsetskips old content (e.g. from a previous restart of the same log file)scenario.compare_checksums(actual, expected, what)- compare two checksum files (as produced byWorker.calculate_database_checksums()), raiseRuntimeErrorwith a unified diff on mismatch
Minimal example
The full contents of scenarios/ci/basic.py:
# Basic CI scenario: randomized workload cycles against a single postgres
# primary with per-cycle restarts.
import logging
from stormweaver import scenario
logger = logging.getLogger("scenario.basic")
def add_arguments(parser):
scenario.add_common_arguments(parser)
parser.set_defaults(duration=30, workers=4, repeat=2)
def main(args):
opts = scenario.finalize(args)
with scenario.single_pg(opts) as ctx:
for cycle in range(opts.repeat):
ctx.workload.run()
ctx.restart_and_wait()
logger.info("cycle %d/%d done", cycle + 1, opts.repeat)
ctx.validate_metadata_or_warn()
print("Scenario completed successfully")
Run it with stormweaver scenarios/ci/basic.py -i /path/to/pg/install.
For a guided tour of the rest of the feature set - custom actions, per-worker registries, mid-run registry changes, restarts, pg_tde encryption verification, all with explanatory comments - read scenarios/demo/basic.py (needs a pg_tde-enabled build; run with --workers 4 --tde on).
Backup-testing patterns
scenarios/ci/pitr.py and scenarios/ci/incremental.py share a shape worth knowing before writing another backup scenario:
scenario.fresh_dir("backups", "archive")up frontsingle_pg(opts, archive=True), thenctx.pg.basebackup(...)for the base backup (extra_args=["-c", "fast"]to skip the checkpoint wait)- run workload cycles, and after each one that needs verifying, checksum with
ctx.make_worker(...).calculate_database_checksums(path) - restore: stop the server (
ctx.pg.stop()), replace the data directory (shutil.rmtree+shutil.copytree, orctx.pg.combinebackup(chain, ctx.datadir)for incremental chains), start it again, and for PITR wait for the recovery pause withscenario.wait_for_log(...)before callingpg_wal_replay_resume() - after restore: a fresh worker's
reset_metadata()+discover_existing_schema()re-syncs in-memory metadata with the restored schema, then checksum again andscenario.compare_checksums(restored, expected, what)
pitr.py additionally copies archive/ to archive-copy/ before the first restore, because recovery writes new timeline history into the archive and would poison it for the next restore iteration.
Going fully manual
single_pg/single_mysql cover a fixed server topology (one server, standard actions, one workload). Reach for the underlying pieces directly - sw.Postgres, sw.Workload, sw.Worker, stormweaver.log.init_logging - when a scenario doesn't fit that shape: multiple independent servers/processes, non-standard directory layout, or comparing across subprocess runs.
scenarios/ci/determinism.py is the example: it runs two seeded workloads in separate subprocesses (each needs its own spdlog logger registry - see the comment at the top of that file for why) and diffs their SQL logs. It builds its own Postgres/Workload/Worker instead of going through scenario.finalize/single_pg, because the subprocess boundary and log comparison logic don't fit the framework's assumptions.
Further reading
- Randomized testing concepts -
Workload/Worker/Action/ActionRegistry, the piecesscenario.pybuilds on - Python actions - registering custom actions
- Determinism - what replays and what doesn't, and the metadata-validation limitation
validate_metadata_or_warn()works around - Config parameters - the TOML config file and CLI flags