β¬οΈ Migrating to v5¶
Meerschaum v5 runs its core, CLI, jobs, Compose, HTTP API, and MCP server in Rust. Your existing data, configuration, Pipe definitions, and Python plugins remain usable. Review the changes below before upgrading a shared installation.
Upgrade checklist¶
- Back up your configuration and database. Keep the old installation available for rollback.
- Check Python 3.11+ and the platform requirements.
- Install the optional packages your plugins and DataFrame code use.
- Review permissions, especially accounts that run remote actions.
- Test one source sync, one transformation, and your scheduled jobs on a copy of the root.
- Check saved Job commands with
mrsm show jobs. Jobs keep the commands 4.x saved (from 5.0.1); re-enter a command that is still blank withmrsm edit jobs NAMEbefore restarting it. - Upgrade the owning installation and restart running services. For Docker, replace the image.
See Stack upgrades for PostgreSQL and extension maintenance. Expand only the sections which apply to your installation.
π§ Who needs to read what¶
| You... | Read |
|---|---|
Run mrsm on a server, in Docker, or with compose, and only use built-in connectors |
Installing, Configuration, Jobs and the CLI, Deployment, Behavior changes |
| Write fetch / sync / action plugins, custom connectors, or hooks | The above, plus Python library users and Plugin authors |
Write @api_plugin routes or MCP tools |
The above, plus API plugins |
Write @dash_plugin / @web_page pages |
The above, plus Web pages and Dash plugins |
| Want to write plugins in Rust | Rust plugins |
| Want to see what is new | New in 5.0 |
β What does not change¶
- Your data and Pipe definitions. Existing tables, keys, parameters, SQL templates, and dependencies remain usable.
- Your configuration. Keep
MRSM_ROOT_DIR, YAML files, environment connectors, Compose projects, and plugin registrations. - Everyday commands.
sync, jobs, schedules, and the Python Pipe API keep their familiar interfaces. - Python extensions. Fetch, sync, action, connector, API, and Dash plugins continue through the Python host.
Some defaults, errors, and permissions deliberately change. The next section lists those differences.
β οΈ Behavior changes at a glance¶
Use this table to check differences in errors, defaults, permissions, jobs, and SQL behavior.
Compare behavior by area
Everything below is deliberate, and each item links to the section with the details. Check the ones that apply to you before upgrading.
| Area | v4.1 | v5 |
|---|---|---|
MRSM_NO_AUTO_INSTALL=1 |
Stopped attempt_import from installing, but v4 still downloaded semver and the API imported uv at startup |
Disables every implicit install, including the dependency prechecks. Installs you ask for (install packages, pip_install(...)) still run. Details |
| YAML tags | !!python/object, /apply, /name, ... in a config file or a pipe's parameters were constructed (they can import modules and call functions) |
Refused with a could not determine a constructor for the tag error, exactly like yaml.safe_load. Only !!python/tuple (what Meerschaum itself writes) is read. Configuration |
| Web console | A Plotly Dash app in the FastAPI server | The same app, served by the Python API host and proxied by the Rust server; the standalone binary shows a pointer page at /dash. Web pages |
| Web terminal | A Tornado process; any logged-in user got a shell unless api.permissions.actions.non_admin was false or the server ran with --secure |
Served by the API server and requires webterm:use (Operator or admin by default); the default Editor role has no terminal. --secure restricts it to administrators. The websocket refuses a cross-origin Origin, a second login works and the shell echoes. Deployment |
| Plugin archives | Symlink and hard-link members were extracted | Refused (Links are not allowed in plugin archives); members are written under the target directory only, with a size cap. Plugin and venv names are one plain path component. Deployment |
pipe.sync() |
A plain (bool, str) tuple |
A SyncResult that unpacks like one and carries inserted, updated, duration, ... Python users |
| Dask | get_data(as_dask=True) and syncing a Dask DataFrame |
Raise a TypeError naming this guide. Use as_iterator=True or as_polars=True; pass ddf.compute() or a generator of chunks to sync(). Python users |
Config, MRSM{}, {{ Pipe() }} |
Python | Rust. The files, keys and substitution are unchanged, and get_config still returns live dictionaries. Config YAML holding dates, bytes or very large integers now loads and writes back unchanged. Python users |
| Writing a config file | Truncated the file, then wrote it | Writes a temporary file and renames it over the target, so a failed write never loses your connector passwords. A new config file is created 0600. Configuration |
| Job names, remote job environment, systemd units | See v4 | Stricter names, the server's environment, a pinned MRSM_PYTHON. Jobs and the CLI |
| CLI daemons | start|stop|restart|show daemons managed a warm interpreter |
There is nothing to keep warm: show daemons reports none and start daemons / start worker explain that actions run natively. Deployment |
| Editors | $EDITOR, else installed pyvim |
$VISUAL, else $EDITOR, else vi. Nothing is installed to open an editor. Deployment |
| Scopes | * entered in edit tokens / edit users was expanded to the built-in scope list; stopping a job over the API required the misspelled scope josb:stop; any user could register a token with any scopes, * included |
* is stored literally and grants every scope, custom and future ones included; POST /jobs/{name}/stop requires jobs:stop (a token that holds the legacy josb:stop is still accepted); a token carries at most its owner's scopes (asking for more is refused with a 403 naming them), and only a token's owner or an administrator may edit, invalidate or delete it (only an administrator may make an invalidated token valid again). Tokens |
A token registered with an empty scope list (scopes=[]) |
Received the default scopes | Holds no scopes (omitting scopes still gives the defaults). Tokens registered that way before keep the scopes they were stored with: review them with edit tokens. Tokens |
sql <label> |
Passed the connection URL with the password to pgcli / mycli |
Prefers installed Rainfrog for supported flavors; keeps Python/plain fallbacks and configurable preferences. Credentials travel through the child environment, outside argv. --nopretty forces the plain client. |
sql <label> "<query>" |
The query went to the driver in one call: SQLite (sqlite3) and MySQL / MariaDB (pymysql) refused a string holding several statements (a; b) with a driver error and ran none of them |
The text is sent as written, so CREATE TABLE t (a INT); INSERT INTO t (a) VALUES (5) runs on SQLite, MySQL and MariaDB like it already did on PostgreSQL and MSSQL (Oracle still takes one statement per call). Only SQL you type is affected |
sql <label> "<query>" that contains select but changes data |
v4 sends any text containing select through read(), which fails after the statement ran and, on most flavors, rolled it back silently: INSERT ... SELECT kept its rows only on MSSQL, and CREATE VIEW ... AS SELECT was kept on MSSQL, MySQL, MariaDB, Oracle and SQLite but not on PostgreSQL or DuckDB |
The statement runs and is committed on every flavor, and the action still prints None after "This result object does not return rows" (DuckDB prints its Count table). |
| DuckDB | The Python duckdb package |
Python hosts share the optional Python driver between Pipe operations and SQLAlchemy. Standalone and base-only installs load libduckdb at run time, downloading it when needed. Explicit MRSM_DUCKDB_LIB selects the native C API and refuses mixed SQLAlchemy access. Installing and running |
| Time zone objects | ZoneInfo('Etc/UTC') from psycopg |
datetime.timezone.utc (equal values). Python users |
| An unreachable database, API or Valkey server | The first query waited for the driver's own timeouts (30 s of retries for a refused PostgreSQL / MySQL connection, minutes for a host that never answers) | The first connection of a command fails at once with sql:main: cannot connect to localhost:5432 (connection refused). Is the database running? (also unknown host, rejected credentials), and a host that does not answer fails after connect_timeout (5 s). Configuration |
--debug and dprint() |
dprint() printed to stdout; --debug showed the Python code's messages |
Debug output goes to stderr (Python's dprint() too), so redirected output stays clean; job logs still include it. --debug and the shell's debug on also show the Rust core's messages (dispatch, connections, every SQL statement, sync stages, engine plans and chunks, worker calls, plugin loading) in the same format, with secrets redacted. Troubleshooting |
MRSM_<TYPE>_<LABEL> variables of an unknown type |
Every such variable became a connector (MRSM_DUCKDB_LIB became duckdb:lib) |
Ignored: only the built-in connector types and the types your plugins register are read. Configuration |
| Registering a pipe, user or plugin twice | mrsm_pipes, mrsm_users and mrsm_plugins had no unique keys: two processes registering the same pipe, username or plugin at once could both insert a row (on Valkey, a second registration overwrote the user's password) |
The first time 5.0 registers, syncs, edits or looks up a pipe or user, it adds the unique indexes uq_mrsm_pipes_keys (connector, metric and location keys; a missing location key counts as one, except on MySQL 5.7 and MariaDB) uq_mrsm_users_username and uq_mrsm_plugins_plugin_name (constraints on Oracle), and the second registration gets "is already registered." / "already exists." (a plugin is updated instead). If a pipe, username or plugin is already registered twice, nothing is deleted and that index waits until you remove the extra rows: mrsm doctor lists them and the DELETE that removes them. v4.1 works with the indexes. Valkey instances register pipes and users atomically. |
Changing a pipe's columns |
Not tracked | A changed datetime / id / primary column is a definition change: an engine run marks the pipe for rebuild. The Syncing Engine |
| Database errors in Python | SQLConnector.read() / exec() / value() and pipe.get_data() / get_rowcount() / get_sync_time() warned and returned None or 0, so an outage looked like an empty pipe |
They raise the database's error (on api: instances: an unreachable server or a 5xx answer). silent=True keeps 4.1's results; a pipe that was never synced still reads as empty. Python users |
'None', 'NA', 'nan', '' and the other null-like strings |
Written as NULL, and matched as IS NULL in params |
In declared text columns and text index columns they are text, written and matched as such; only real nulls are NULL. Other columns, and '' on Oracle, keep 4.1's rule. Python users |
| Colors | Output stayed colored when piped or redirected | Styled only on a terminal; NO_COLOR turns styles off everywhere and FORCE_COLOR forces them. Jobs keep their colors. Jobs and the CLI |
mrsm -h, mrsm help |
argparse's page; mrsm help ran your shell's help builtin |
Grouped actions first, then the options. Every option string, arity and type is unchanged. Jobs and the CLI |
start pipeline |
The serialized form of an action chain (start pipeline <flags> -P {...}) |
Starts a saved Pipeline's background job. Chains keep their + / : syntax, and jobs stored in 4.1's form keep running. Jobs and the CLI |
| Executor of a non-default root | local |
systemd on a systemd host, in every root; a root's local jobs move on its next compose up or restart jobs. Jobs and the CLI |
copy pipes |
Succeeded when any one pipe was copied | Fails (exit 1) when any pipe fails, with a line per pipe. Jobs and the CLI |
copy pipes --sync-data |
incremental ignored --begin, --end and --params and copied the whole history into a destination without a sync time; -y skipped existing pipes; all and backtrack ignored those flags |
incremental copies each destination pipe's new rows (from its own sync time minus its backtrack window, within those flags), keeps an existing pipe's attributes without --force, and fails a pipe that has rows but no datetime axis; all and backtrack refuse those flags. Jobs and the CLI |
mrsm python script.py ... |
Ran script.py as a line of code in the REPL |
Runs the file; every word after its name is the script's. Jobs and the CLI |
Pipes with the connector keys mrsm |
Listed with your pipes | Hidden from listings and bulk actions unless you name them with -c mrsm. Python users |
/dash |
The dashboard (key filters, command builder, web terminal) | The Overview. The dashboard is the Terminal page at /dash/terminal, and /dash?action=... links open it. Web pages |
| Console callbacks | Answered any request | Need a console session, except the login and registration forms and the callbacks a plugin marks public. Web pages |
| A user's scopes that cannot be read | A failed read granted every scope | The login or request is refused (503); Valkey's user reads raise ConnectionError. Permissions |
| Sign-ins (JWTs, console sessions) | Outlived a password change, and a re-registered username inherited them | Bound to the current account and password; sign in again once after upgrading (note). Permissions |
| A deleted user's API keys | Kept working on plugin routes | Removed with the user; every credential needs a current owner. Permissions |
| The first account of a new instance | Two registrations at once (or two servers) could both become administrators | One administrator at most, decided atomically on SQL and Valkey instances; on custom instances, create it with mrsm register users. Permissions |
Plugin routes (ScopedAuth, CurrentScopes) |
Trusted a JWT's scopes, or an API key's stored scopes | The owner's and the token's current permissions, as on the server's own routes. Permissions |
| Request bodies | No limit: any body was read whole | Limited before they are read: 64 KiB for login, registration and tokens, 16 MiB for other JSON and form bodies and for requests forwarded to the API host, 1 GiB for pipe data sync. A larger body gets 413; api:limits changes the limits. A forwarded request the API host does not start answering within 300 seconds gets 504, and a host that stops answering its health checks is restarted. Request size limits |
start api --keyfile/--certfile |
HTTPS through Uvicorn | Native HTTPS with rustls when a key and certificate are supplied; plain HTTP otherwise. Deployment |
| Web terminal and tmux | Sessions on your default tmux server | A tmux server of its own under the Meerschaum root, with its own configuration. Deployment |
Signing in after the upgrade
JWTs and console sessions issued by 4.1 or by an earlier 5.0 release candidate are refused, so each person signs in once more. Long-lived API keys (mrsm-key:...) keep working while their owner exists. An api: connector that logs in with a username and password logs in again by itself: it retries a refused read once, while the first write after the upgrade can return 401 once. Changing a password, or deleting a user, ends their sign-ins within the server's identity cache (a few seconds) and open terminals within their check interval.
π¦ Installing and running¶
Use Python 3.11 or newer. Install meerschaum[dash] for the console, meerschaum[pandas] or [polars] for your DataFrame library, and your plugins' declared dependencies.
Platform, installation ownership, and optional dependencies
- Python 3.11 or newer (only needed when you use Python plugins, the Python API or an extra). 3.10 reaches end of life in October 2026 and is not supported by the v5 wheels.
pip install meerschaumstill works and is the only PyPI package. Itsmrsm(andmeerschaum) console script runs the native CLI from the wheel's extension, andpython -m meerschaum ...runs the same code.cargo install meerschaumbuilds the same CLI as a standalonemrsmbinary: both install Meerschaum, likepolars. The console script pays for loading the extension:mrsm show versiontakes about 35 ms (the Python interpreter alone is 15 ms) against about 10 ms for the standalone binary (measured on a Linux x86_64 development machine with the release build). Jobs,systemdunits and compose steps runpython -m meerschaum, so they carry the same start-up cost per process.-
The base wheel has no third-party dependencies. It depends on nothing. Build tooling is maturin;
setup.pyis gone, so build from source withpip install .(maturin backend) ormaturin build. v4.1 installed about 110 packages into the sharedmrsmvenv on first run (pandas, dask, SQLAlchemy, three PostgreSQL drivers, Dash and its component libraries, rich, FastAPI, and so on). v5'smeerschaumwheel contains the Rust core and the Python SDK and pulls in nothing else. Everything heavier is an extra, imported only when used:Extra Installs You need it when meerschaum[pandas]pandas, pyarrow You retain dataframe: pandason an upgraded root, requestas_pandas=True, or work with pandas inputs. A missing library raises anImportErrornaming the extra (or installs it into themrsmvenv when automatic installs are allowed).meerschaum[polars]polars You use the new-root default, dataframe: polars,as_polars=True, or Polars inputsmeerschaum[geo]shapely, pyproj, GeoPandas, geoarrow-types (GeoPolars when released) You use Python geometry objects or request a GeoDataFrame; Polars reads can keep WKB geometry without GeoPandas meerschaum[sql]SQLAlchemy, psycopg, PyMySQL Explicit conn.engineaccess and caller-provided SQLAlchemy connections. Native reads, writes and pipe operations do not need it.meerschaum[sql-mssql],[sql-oracle],[sql-duckdb],[sql-cockroachdb]pyodbc; oracledb; duckdb + duckdb-engine; sqlalchemy-cockroachdb Explicit SQLAlchemy access against those flavors; [sql-duckdb]also provides DuckDB's optional shared Python transport. Native raw SQL does not require these extras.meerschaum[api]FastAPI, uvicorn, a2wsgi, python-multipart, PyJWT, websockets Any installed plugin uses @api_plugin,@web_pageor registers an MCP toolmeerschaum[dash]Dash, a2wsgi, dash-bootstrap-components, dash-extensions You want the web console ( /dash), or any installed plugin uses@dash_pluginor a Dash@web_page. Includes theapiextra, dash-ace and pandas, sopip install meerschaum[dash]is a working console. The host builds the console's app withdash_extensions.enrich.DashProxy(Trigger, duplicate outputs, as in v4) when dash-extensions is importablemeerschaum[shell]ptpython You use mrsm pythonmeerschaum[gui]pywebview You use mrsm start guimeerschaum[cli-tools]pgcli, mycli, litecli, gadwall You use mrsm sqlto open a database shellmeerschaum[full]pandas,polars,geo,sql,api,dash,shellYou want all the optional Python integrations; the root's dataframesetting still selects returned framesThe Docker image installs
meerschaum[full]; a newbmeares/meerschaum:slimtag installs only the base wheel.Gone from every extra (they served code that is now Rust):
dask,partd,joblib,psycopg2-binary,asyncpg,databases,aiosqlite,aiomysql,GeoAlchemy2,rich,prompt-toolkit,pygments,tabulate,humanfriendly,python-daemon,dill,psutil,watchfiles,gunicorn,httpx,valkey,python-jose,fastapi-login,aiofiles,python-dotenv,envyaml,terminado,tornado,dash-daq,Flask-Compress,modin,cmd2,ruamel.yaml. If your plugin imports one of these, declare it inrequired(see Dependencies and venvs). -
DuckDB uses an optional Python driver or a native library. In Python, install
meerschaum[sql-duckdb]and leaveMRSM_DUCKDB_LIBunset so Pipe operations and SQLAlchemy share one driver. The standalone binary and base-only Python installs use DuckDB's own shared library,libduckdb(v1.5.6, MIT, published by the DuckDB project; Meerschaum does not redistribute it). When no cached or system library is available, the first native connection downloads the release asset for your platform (about 41 MB) intoMRSM_ROOT_DIR/drivers/duckdb/<version>/, after checking it against a SHA-256 pinned in Meerschaum. Later native runs reuse it.mrsm install driver duckdbdoes the download on purpose (--forcerepeats it),mrsm uninstall driver duckdbremoves it, andmrsm show driversreports each driver's version, source (env,cache,systemormissing) and path. Behind a TLS-intercepting proxy the download trusts the operating system's certificate store as well as its bundled roots, and honorsHTTPS_PROXY; if it still cannot reach GitHub, runmrsm install driver duckdbon a machine that can and copy the library. Air-gapped hosts: copy a DuckDB library (v1.4 or newer) there and setMRSM_DUCKDB_LIBto its path, or put it on the system library path;MRSM_NO_AUTO_INSTALL=1forbids the implicit download and a missing library then fails with a message naming both fixes. In Docker,--build-arg duckdb_driver=1bakes it into the image's root. On Linux the library needs the C++ runtime (libstdc++), which both Docker images ship. Native Pipe sessions run in UTC (the official build bundles ICU, whose default is the host's zone), aware timestamp parameters keep their instant, and nested, interval and enum values read as the text DuckDB prints. - No Python at all is an option:
cargo install meerschaum --locked(Rust 1.95 or newer) or a release binary runs without an interpreter as long as no Python plugin, custom Python connector, API plugin or Dash plugin is used. PointMRSM_PYTHONat an interpreter when you add one. - Windows limits. The Python API host is reached over loopback TCP with a per-start token on Windows (a Unix socket elsewhere), so
@api_pluginroutes, the Dash console and plugin pages work. Background jobs (-d), including Pipe management and Pipeline workers, are still refused; run those in Docker or WSL. Dashboard Exec submits into the existing terminal and does not require a background job. - Removed:
pyoxidizerbuilds and the standalone "portable" archive.pip install meerschaumand the standalone binary (cargo install meerschaum, release archives) replace them.
Upgrading an installation¶
mrsm upgrade mrsm upgrades the installation providing the running engine. The planner
selects one published GitHub release and installs that exact release through the owning
backend: Python's environment installer (or its uv/pipx tool manager), Cargo using its
recorded install root, or checksum-verified replacement of a downloaded executable.
mrsm doctor reports the upgrade owner. Standalone development binaries and system-managed binaries
report the appropriate rebuild or package-manager instructions.
A stable install selects stable releases; an install of a pre-release keeps accepting
pre-releases. Use mrsm upgrade mrsm -A --pre to opt into pre-releases from a stable install, or
mrsm upgrade mrsm -A --version 5.0.2 to select an exact release. An intentional
downgrade requires --force. Sub-arguments after -A that are not release options go to
the selected installer; a downloaded binary has no installer arguments.
MRSM_PYTHON chooses the optional Python extension host, never the self-upgrade destination.
When a distinct host is explicitly selected, the wizard offers to align its Meerschaum
package with the planned release. The default is no; --yes does not opt a separate host
in. An incompatible selected host must be aligned or changed before upgrading. Passing
Python extras (for example upgrade mrsm full) explicitly requests their installation in
the owning Python environment or the selected host. Native-only upgrades neither create
nor require a Python environment. Existing compatible user environments stay independently
managed. Installer failures are reported, including partial completion if a coordinated
host update succeeded before a CLI update failed.
After verification, an interactive shell restarts with the updated engine. Other running processes must be restarted separately. Containers are upgraded by replacing their image; see Docker variants.
βοΈ Configuration¶
The existing YAML layout and environment connectors remain. Safe YAML loading, connection timeouts, and recognized connector types have stricter behavior.
Configuration compatibility details
Everything under MRSM_ROOT_DIR/config/ is read unchanged. Things to know:
api.uvicorn.*keys (such asforwarded_allow_ips) are honored with a deprecation notice and mapped toapi.server.*. Rename them at your leisure.- YAML is parsed by Meerschaum's own codec. It reads and writes what v4 (PyYAML) did, byte for byte on the files Meerschaum writes, and keeps dates, bytes and very large integers intact. The one deliberate difference: the full-
Loadertags!!python/object,!!python/apply,!!python/name,!!python/moduleand friends are refused likesafe_loaddoes, so a config file or a pipe'sparameterscan never run code.!!python/tupleis still read. A lone surrogate escape ("\ud800") loads as U+FFFD. - Config files are written atomically.
edit config,write_configand the actions that save configuration write a temporary file in the same directory and rename it over the target, keeping its mode, owner and group (a new file is0600). A failed write never deletes or truncates the existing file. On a single bind-mounted file (/meerschaum/config/plugins.jsonin Docker) or a writable file in a read-only directory, the write falls back to rewriting the file in place, as v4 did. - Default pipe configuration may appear on startup. Most v5 commands load the pipe defaults and can create
config/pipes.jsonwhen it is missing. This file contains configuration defaults; registered pipes remain in their instance database.mrsm doctoruses read-only configuration and does not create it. -
New:
api.permissions.rate_limits, keyed by route group (or a path prefix such as/my-pluginfor a plugin's routes), so you no longer need HTTP middleware to throttle/login(see API plugins and Permissions and Rate Limits):{max, window_seconds}is accepted as the same. An entry the server cannot use (a count below 1, a window that is not a positive number, an unknown key, a name that matches no route group) is reported at startup. -
New:
api.permissions.preset: public(ormrsm start api --public) serves only/login,/healthcheck,/version, plugin routes and plugin pages, for an API that exists to serve a plugin's public pages; route groups you allowlist are served on top. See The public preset. - New:
protected: trueon a connector (or?protected=trueon its URI) makes Meerschaum refuse every change through it until you pass--allow-protected, setMRSM_ALLOW_PROTECTED=1or usemeerschaum.connectors.allow_protected(); reads are never blocked.protectedis therefore a reserved connector attribute: a connector that used an attribute of that name for something else gets this behavior. See Protected Connectors. - New:
api.dash.callbacks_require_session(defaulttrue; see Web pages) andapi.webterm.tmux.config, a tmux configuration file of yours for the web terminal's own tmux server (see Deployment).api.cache.connector(defaultvalkey:main) also backs the plugin SDK's cache helper. meerschaum.executor: localin a root's config (or a Compose project'sconfig:) keeps that root's jobslocalnow that every root defaults tosystemdon a systemd host (see Jobs and the CLI).- Unknown
MRSM_<TYPE>_<LABEL>variables are ignored. v4 turned every such variable into a connector (MRSM_DUCKDB_LIBbecame aduckdb:libconnector). v5 reads a variable only when<TYPE>is a connector type that exists:sql,api,valkey,plugin,systemd, or a type one of your plugins registers with@make_connector. Everything else is skipped (a debug message names it). - Connections fail fast. The first connection a command makes to a SQL, API or Valkey server reports a refused connection, an unresolvable host or rejected credentials immediately, and a host that does not answer fails after
connect_timeoutseconds (a connector attribute, orMRSM_CONNECT_TIMEOUTfor every connector; 5 by default). A job that loses a connection it had established keeps v4's retry behavior. Serverless or auto-resuming databases (Azure SQL serverless, Neon, Aurora Serverless) can take 15 to 60 seconds to wake: raiseconnect_timeoutfor those connectors, and note that a job started before its database is up now exits at once instead of retrying for 30 s, so give itrestart: always(compose) or a systemd unit withRestart=. SeeMRSM_CONNECT_TIMEOUT. - New:
system.engine.*keys for the Syncing Engine (workers,python_workers,python_worker_max_calls,python_worker_max_memory_mb,python_worker_idle_seconds,limits.connectors,limits.instances,auto_rebuild,implicit_edges,inclusive_cap,flat_ledger,events,state_flush_ms,run_history_days,debounce_seconds,chunk_pipelining,default_duration_seconds). All have defaults; you do not need to set any of them. The full table is in Engine State.
Run mrsm show config on v5 against a v4.1 root directory to see the resolved config.
π Jobs and the CLI¶
Existing jobs are discovered in the same root. Installed systemd hosts now use systemd for every root; debug logs go to stderr and piped output omits terminal styles.
CLI and job changes
Core actions behave the same and take the same flags: every option string, dest, arity, type and choice is diffed against the 4.1 parser in CI, argument for argument. The help text is new: mrsm help, mrsm -h, mrsm show help and the shell's help start with every action in groups (Create, Sync, Run, Inspect, Maintain, Configure) with a one-line description, then your plugins' actions and their subcommands, then the options (the graph-run flags under Pipeline options). Scripts that compared mrsm -h with 4.1's page byte for byte need updating, and mrsm help prints this page instead of running your shell's help builtin (bash -c help runs that one). mrsm show data --gui, --trace, --cache, --no-daemon, --webterm-port, --use-bash and --allow-shell-job are accepted and have no effect, and their help says so.
Removed or renamed:
| v4.1 | v5 |
|---|---|
--engine laps |
Gone. --loop is the only long-running mode and reproduces lap behavior (below). |
mrsm install plugin compose |
A no-op with a notice. Compose has been built in since 4.0. |
| A switch to "use the Python core" | Does not exist in 5.0. There is one core, and it is Rust. |
start pipeline <flags> -P '{"sub_args_line": ...}' (how 4.1 stored an action chain) |
Still runs, and jobs stored that way keep running. Write the chain as a + b : <flags>; start pipeline NAME now starts a saved Pipeline's background job (below). |
start api --keyfile ... --certfile ... (HTTPS through Uvicorn) |
Native HTTPS with rustls when a key and certificate are supplied; plain HTTP otherwise. Deployment |
Job executors (systemd, api:<label>) in 5.0. Three behaviors differ from 4.1, all deliberate:
- Job names are one path component.
.,.., the empty name and names containing/,\or NUL are rejected (4.1 only banned,;'"$#=*&!+ backtick +~). A name is a directory underjobs/, soDELETE /jobs/..would otherwise address the whole root. - The environment of a job created over the API is the server's. As in 4.1,
POST /jobs/{name}replaces whateverproperties.envthe client sent with the server's environment. The one exception is the Compose project stamp, which is kept so that projects sharing one remote executor still own their jobs (4.1 loses it). A client cannot set other environment variables of a remote job. - Systemd units pin the Python interpreter. The unit runs the
mrsmbinary and carriesEnvironment=MRSM_PYTHON=<absolute path>: theMRSM_PYTHON(else the firstpython3onPATH) of the process which created the job. Plugins run under that interpreter however the unit is started later.systemctl --user stopof a job asks it to stop once; it stops at its next checkpoint, as with a single Ctrl+C.
Every root uses systemd. 4.1 picked the systemd executor only for the default root; other roots, Compose projects with their own root_dir included, ran local jobs that did not survive a reboot. On a systemd host an installed 5.0 picks systemd for every root. Units are named mrsm-<job>.service in the default root (as in 4.1), mrsm-<project>.<job>.service for a Compose project and mrsm-<8 hex digits of the root's hash>.<job>.service in any other root; they carry the root, the plugins directory and the project's environment, unit files are written 0600, and each root lists only its own units (the default root no longer lists other roots' mrsm-*.service). A root's local jobs move to systemd on its next compose up or restart jobs: the old process is stopped first and its logs/<job>.log.* move to systemd/logs/. To keep a root's jobs local, set meerschaum:executor: local in its config (or the project's config:). A development build, a binary under a Cargo target directory and a package imported from a source checkout never pick systemd by themselves (-e systemd still works). Compose roots started from a system service or cron without a user bus need that setting too.
start pipeline and action chains. Commands joined with +, with flags for the whole chain after :, are called action chains; type them as before (show version + show arguments : --loop). A chain that runs as a job is stored under a hidden internal action, so show jobs prints it as you typed it, and jobs 4.1 stored as start pipeline ... -P {...} keep running unchanged. Chain messages say "action chain" instead of "pipeline" ("Ran the action chain 3 times.", "The action chain did not run.", "The action chain failed after 2 steps.", "Cancelled the action chain."), and edit jobs says "Chain Arguments:". start pipeline NAME / stop pipeline NAME (plurals and several names work) start and stop a saved Pipeline's background job: its scheduled job when it has one, else a new job pipeline-NAME running sync pipelines NAME -i <instance> --loop --min-seconds 60. start pipeline with neither a name nor a chain payload asks for a Pipeline's name (4.1 said "Nothing to do.").
--loop works with any action, and jobs report their health. --loop repeats any action --min-seconds apart (default 1), plugin actions included, retrying a failure after a pause that grows while it keeps failing (capped at an hour); stop jobs or Ctrl-C ends it after the current run. A plugin action whose function takes a loop parameter keeps 4.1's contract: --loop passes loop=True and the action repeats by itself. Every other action is repeated by Meerschaum and called once per run with loop=False. A job that repeats its work records each lap in jobs/<name>/laps.json (deleted with the job), and show jobs, job.health and the console report healthy, failing, stalled (no lap ended for three times the expected interval, at least a minute; an hour for --listen) or stopped. See Job health.
GET /jobs redacts credentials, and the console's jobs are private. 4.1 returned every job's daemon.properties.env (the server's environment when the job was created: each MRSM_SQL_* / MRSM_API_* connector URI with its password, and any other secret the process held) to every caller with jobs:read. 5.0 masks the password of connector URIs and the value of credential-named variables (*_PASSWORD, *_TOKEN, *_SECRET*, ...), also inside JSON values such as MRSM_CONFIG, in GET /jobs and GET /jobs/{name}; the other variables are unchanged. Automatic workers created by Pipe management and Pipeline runs are visible to their creator, their teamβs managers and administrators only. Each worker's kind and owner are recorded with the job, so workers with custom names are covered too; a job record without them whose name begins with execute- is treated as an automatic worker. The Exec button uses the existing terminal and does not create an automatic worker.
Jobs start staggered (new in 5.0). In 4.1 every job started the moment it was asked to, so compose up, restart jobs or a reboot bringing all your systemd units up at once started every worker, imported every plugin and ran every first lap on top of each other. In v5 each job passes a per-root startup gate first: a random delay of up to jobs.startup.jitter_seconds (default 30), a wait while the machine is busy (jobs.startup.max_load x CPUs, jobs.startup.min_free_memory_percent), and a slot (jobs.startup.max_concurrent, default max(2, CPUs / 2)) held until the job's first lap is done, the job goes idle (jobs.startup.settle_seconds, default 15) or jobs.startup.slot_timeout_seconds (default 300) pass. An explicit mrsm start job x skips the delay and the busy check (it still waits for a free slot), and --no-stagger skips the gate altogether. Bulk starts, restart jobs and unit restarts get all of it, so a job may begin a few seconds after you start it. Generated units also carry RestartSec=5. -s schedules take an optional fixed per-job offset: -s 'every 5 minutes jitter 30 seconds', --jitter 30s or jobs.schedule.jitter_seconds (default 0).
Ctrl-C, secret prompts and the shell.
- Ctrl-C cancels the running action and nothing else. In the shell it prints
Interrupted.and returns to the prompt, which keeps working (at an idle prompt Ctrl-C only clears the line; Ctrl-D orexitleave).mrsm <action>exits with status 130 and printsInterrupted., with no panic or traceback. A program the shell started (sh,python) receives the terminal's Ctrl-C itself and the shell waits for it; a second Ctrl-C within two seconds kills a program that ignored the first (an interactive REPL is never killed). Actions that stop on a flag (--loop,--listen, followed logs) still finish the current step first. A graph run (--parents,--children) cancelled this way hands the leases of its running pipes back before it exits, so a rerun does not wait for them to expire. - Secret prompts echo
*. Every password prompt, and each secret connector attribute asked bybootstrap connectors(password,api_key,secret,token,client_secret, ... by name), shows one*per character; Backspace erases one. Nothing is echoed when stdin is not a terminal. v4 echoed nothing at all. mrsm pythonneeds the package. It asks the interpreter it would start whether it canimport meerschaumand, if not, fails withThe meerschaum Python package is not installed for <interpreter>; install it with pip install meerschaum==<version> to use mrsm python. Nothing is installed and no plain REPL opens instead.mrsm python script.py [args ...]runs the file with the interpretermrsm pythonwould start (--venvpicks a virtual environment's). Every word after the file name is the script's, unchanged, as withpython script.py ...(mrsm python script.py --dry-run -ygives the script both flags); Meerschaum's own flags go before the file (mrsm python --venv tools script.py), and[...]before it are the interpreter's (mrsm python [-u] script.py). 4.1 ranscript.pyas a line of code in the REPL.bootstrap pipessuggests keys. The connectors are listed before the question, and the connector, metric and location questions show a dropdown which filters as you type (connectors from the configuration,MRSM_<TYPE>_<LABEL>variables and plugins; metrics and locations already registered on the instance). Unknown connector keys are accepted with a warning; v4 refused them. For asql:connector the questions come in a new order: the query first (in your editor on a terminal, one line otherwise), a preview of its first rows, then the datetime and ID columns as numbered choices from the preview, the target and the tags; it no longer asks whether to edit the definition afterwards, and "Would you like to try syncing ... now?" defaults to yes. Scripts that pipe answers into it must follow the new order (and give the query as one line); other connector types keep theirs.--versionand--helpnever start a Python worker. Help for a plugin's action reads the plugin metadata the last plugin-loading command cached, and says when the plugins changed since (runmrsm reload, then ask again).- Errors are printed once, and say what to do. An action's error appears once, in the failure block, which no longer ends with "Run again with '--debug' to see a full stacktrace." An undefined connector says so ("Connector 'sql:nope' is not defined. Add it to the config (
mrsm bootstrap connectors), setMRSM_SQL_NOPE, or install the plugin that provides it.") where 4.1 said "Missing flavor" (or "Incorrect attributes" forapi:). When the defaultsql:main, as shipped, cannot be reached, the error adds the next step:mrsm stack up -d db, or the built-in SQLite instancesql:local.
show actions and --nopretty.
show datatruncates a long table (pretty output only). v4 printed every row, column and cell. v5 prints a table the way a pandas frame is printed: more thanformatting:dataframe:max_rows(default 60) rows showmin_rows(10) of them, the first five and the last five around aβ¦row; columns that do not fit the terminal are replaced by oneβ¦column in the middle; a cell longer thanmax_colwidth(50) characters ends inβ¦; and the footer states the true size,[200 rows x 4 columns (showing 10 rows)]. Only the rows that are shown are read from the instance (the first and the last rows by the datetime axis plus a row count; a pipe without a datetime axis reads its first rows and the count), soshow data allon a large table is cheap.--allprints everything as v4 did, and--noprettynever truncates. The keys live underformatting:dataframeinconfig.yaml(max_columnsfixes the column count instead of fitting the terminal width); they have no default entry, soshow configdoes not list them until you set one. The width is the terminal's (or$COLUMNS); output that goes to a pipe or a file keeps every column.- The per-pipe
showviews run concurrently.show columnsandshow datajoinshow rowcounts,show sizesandshow partitions: the pipes are read in parallel (--workers, default 4, within the connector's pool) and printed in the order of the selection. Instances that cannot run reads in parallel (DuckDB, in-memory SQLite, plugin connectors) keep reading one pipe at a time. Actions that change data (clear,drop,delete,verify --yes) stay sequential. show sizescounts a shared table once. Pipes whosetargetis the same table (same database, schema and table) are markedshared xN, and the total counts that table once; v4 added its size once per pipe.--noprettyexits1when the command fails. v4 always exited0with--nopretty(it only set the exit code when it printed the result), so scripts had to parse the output. A failed command still prints nothing extra in this mode.show pipesnames failing pipes. Its pretty output ends with one line for each selected pipe whose latest sync failed (Pipe('sql:main', 'rollup', 'home') failed its last 3 syncs (latest 2026-10-27 07:39:35 UTC): ...) and adds nothing while every latest sync succeeded; parse--noprettyoutput instead of the tree.show actionssizes its columns to the names instead of cutting them at 80 columns.copy pipesfails when any pipe fails. It prints a line per pipe that failed or was skipped (Copied 1 of 3 pipes to 'sql:dest' (1 failed, 1 skipped).) and exits1; 4.1 exited0as soon as one pipe was copied. When every pipe is already on the destination (skipped without--force) it succeeds (4.1: "No pipes were copied.", exit1). When every pipe is copied, the message is 4.1's.copy pipes --sync-data incrementalcopies only new rows. Each destination pipe gets the source's rows from its own sync time minus its backtrack window (within--params, from a later--begin, until--end); older destination rows are never rewritten. Without--force, an existing destination keeps its attributes and locks and gets the new rows (4.1 skipped it). A destination proven to have no rows is filled from--begin, else completely; one with rows but no datetime axis, or whose rows cannot be counted, fails, so useallorfilterfor it.--sync-data allandbacktrackrefuse--begin,--endand--params(4.1 ignored them and copied everything); usefilterfor exactly those rows.copy pipes --sync-datanever drops rows past anapi:server's row limit. It reads one chunk (verify:chunk_minutes) at a time, as 4.1 did. From anapi:instance each read (backtracktoo) asks for at mostapi:data:max_response_row_limitrows; a full read is read again in halves, and rows that cannot be split (more than the limit at one timestamp) fail the copy, where 4.1 kept the first rows silently.- A pipe whose source is an
api:pipe syncs every row of its source. Its fetch reads the source one chunk (the source'sverify:chunk_minutes) at a time, as 4.1 did, each read asking for at mostapi:data:max_response_row_limitrows; a full read is read again in halves, and rows that cannot be split fail the sync, where 4.1 kept the first rows silently. A refused read (a lost permission, a rate limit) fails the sync instead of reading as an empty source, and a one-framefetch()of such a pipe fails instead of answering a cut frame. Setmax_response_row_limiton anapi:connector whose server allows fewer than 100,000 rows per read to skip the first refused read. - Colors follow the destination.
show,sync, tables and messages use the same styles as v4, from the sameformattingconfig, but only on a terminal: output redirected to a file or piped to another program is plain text (4.1 kept the escape sequences; setFORCE_COLOR=1where a script needs them), and standard output and standard error decide separately (--debug 2> debug.logwrites a plain log while the terminal stays colored).NO_COLOR(any non-empty value) turns every style off, bold and dim included, while the full-screen views keep working;formatting:ansi: falsealso turns the full-screen views off. Jobs keep their colors, so their logs stay colored in the console, andshow logsprints them plain when its own output is not a terminal. The same rules apply to Python output throughmeerschaum.utils.formatting(a Jupyter notebook counts as a terminal). See Colors and plain output.
--loop --min-seconds N behaves exactly as laps did for a flat selection: every selected pipe syncs, then the process sleeps N seconds after the lap ends, exactly as v4 does. Jobs like these need no change:
mrsm sync pipes -c plugin:acme -m readings --loop --min-seconds 300 -w 1 -d --name acme-readings
mrsm sync pipes -t nightly -l _staging -s 'daily starting 02:00' -d
--listen keeps working for streaming connectors written in the v4 style (a sync() that subscribes and returns while callbacks call pipe.sync()). The engine keeps the worker that ran sync() alive and supervises it with restarts. You do not need the old --loop --min-seconds 144000000 workaround anymore; if a job still uses it, it continues to work.
Compose¶
mrsm compose explain, init and up --dry read the project's real instance connectors, with the same precedence as v4: the project's config: block, with the MRSM_SQL_<LABEL> / MRSM_API_<LABEL> variables of the project environment layered over it, and the connectors the file itself defines winning for the attributes they set. Registered parameters are compared as stored, and {{ Pipe() }} references render to the quoted, schema-qualified table.
explaindoes not create databases. v4 creates the SQLite or GeoPackage file of a project-defined connector (with itsmrsm_*tables and cache files) just by listing which pipes are registered. v5 treats a file instance whose file does not exist as having no registered pipes and never connects to it. The statuses are the same (every pipe is "Not registered"); the file appears when a pipe is registered.up --drystill registers. As in v4,--dryonly skips starting jobs: it registers missing pipes, updates changed parameters and removes the project tag from pipes the file no longer defines.upchecks job ownership first. If a job it would start has the name of another project's job,upstops with the list of names before it registers or syncs anything (4.1 refused only after the initial syncs).- Jobs move to systemd. A project's jobs use the
systemdexecutor on a systemd host, also under its ownroot_dir, as units namedmrsm-<project>.<job>.service; the nextupmoves the project'slocaljobs (see Every root uses systemd). --allow-protectedis forwarded to every command and job compose runs, like--debug, for projects whose instance is a protected connector.explainis side-effect free. v4 initialized the root directory, wrote.compose-cache.pkland installed requirements before explaining. In v5explaincreates nothing, writes no cache, installs nothing and fetches no plugin; it reports whatupwould install. It prints v4's colors (instance panels,pipe_reprstyles, JSON).- Missing references are findings, and
up/run/syncname them.explainlists every connector, instance, plugin,MRSM{...}symlink and{{ Pipe() }}reference that does not resolve on this host and keeps going.up,runandsync pipesrefuse to start on a connector that is not defined, withconnector 'mqtt:x' is not defined on this host (referenced by config.meerschaum.connectors.mqtt.x)instead of v4'sTypeErrortext. Every compose error exits non-zero. --fileand-f <file>.yamlwork in any position; a bare-fstill follows the logs. New flags:--project,--standalone,--no-stagger.compose init -c/-m/-l/-t/-iadds the matching registered pipes to the new file.- New:
include:andparent:. One root compose file can include others (files, directories, globs); every command spans the tree, the root's values win, and jobs started from a root carry the labelMRSM__COMPOSE_ROOT. See Compose. Additive: files without these keys behave as in v4. - An unreachable instance fails the subaction (except in
explain). As in v4, listing the registered pipes raises (Exception) when the instance cannot be reached. The message carries the driver's own error text, which differs from SQLAlchemy's. - Plugin-defined instance connectors (a custom connector type from a project plugin) go through the Python worker like on any other native action. The parity tests cover
sql:andapi:instances.
The Syncing Engine¶
v5 understands the pipe graph (parents, children and {{ Pipe() }} references) and adds:
mrsm sync pipes -c sql:main -m daily_rollup --parents # ancestors first, in dependency order
mrsm sync pipes -c sql:main -m raw_events --children # then everything downstream
mrsm sync pipes -t reports --parents --resume <run_id> # re-run only what failed or never ran
The engine also drives mrsm plan pipes (a dry run), mrsm show graph, mrsm show runs / show run <id>, pipe.sync(parents=, children=), mrsm.Pipeline, --loop / --listen with a selection that has edges, and the Dash console's /dash/pipelines page. Run history is stored on your instance (the mrsm_engine_* tables).
The full guide, with real output, is Pipelines. A bare sync pipes without --parents or --children keeps v4 semantics: no capping of a child at its parents' watermark, no rebuilds, no ledger-driven backfills. Two behaviors are new inside engine runs only (--parents, --children, mrsm.Pipeline, and --loop with a selection that has edges):
- A child is never synced past the minimum of its parents' sync times.
- Changing a pipe's definition (its
sql,fetchconfig,dtypesorcolumns) marks it for rebuild (clear, then full sync). Interactively you are asked; in a job the pipe is markedrebuild_pending, descendants keep running on the stale data, and a warning is logged on every run until someone confirms with--yesor setssystem.engine.auto_rebuild: true. Rebuilds never block a job. - A pipe with its own
schedulethat has parents in the run syncs when its parents have new data, not on its schedule; the loop warns about it once andplan pipeslists it. In a flat selection every pipe follows its schedule.
Saved Pipelines run in the background with mrsm start pipeline NAME (see Jobs and the CLI): their job runs sync pipelines NAME --loop, which uses the engine's continuous mode like sync pipes --parents --loop and picks up edits of the Pipeline within half a minute.
Every sync, bare ones included, also records the pipe's sync status (last attempt, success and failure, message, row counts, duration, failures in a row, what ran it) in one row per pipe of mrsm_engine_sync_status, created by the first recorded sync (in _mrsm_internal on PostgreSQL and MSSQL, beside the instance tables elsewhere). It is the one engine-state row a bare pipe.sync() writes (no ledger, cursor or run history), it is written in the background and never fails a sync, and mrsm and temporary pipes have none. Read it with pipe.get_sync_status(), mrsm.get_sync_statuses() or GET /engine/sync-status. See Sync status.
π Python library users and scripts¶
SyncResult still unpacks into (success, message). Check database exception handling, DataFrame defaults, and code which accesses SQLAlchemy directly.
Python compatibility details
import meerschaum as mrsm and the public API are unchanged. mrsm.Pipe, mrsm.get_connector, mrsm.get_config, pipe.sync, pipe.get_data, pipe.get_sync_time, pipe.get_rowcount, pipe.verify, pipe.filter_existing, pipe.enforce_dtypes, pipe.parameters (still a live dict: assign, then pipe.edit()), pipe.columns, pipe.dtypes, pipe.upsert = True, pickling, equality and hashing by keys.
Things to check:
- Database errors raise.
SQLConnector.read(query),exec(query)andvalue(query)raise when the query fails (a missing table, a syntax error, an unreachable server) instead of warning and returningNone;value()of a query without rows is stillNone.pipe.get_data(),pipe.get_rowcount()andpipe.get_sync_time()raise when the instance cannot be read, where 4.1 made an outage look like an empty pipe; a pipe whose table does not exist yet still returnsNone,0andNone. Onapi:instancesAPIConnector.get_pipe_data()and these pipe methods raiseConnectionErrorwhen the server cannot be reached and an error when it answers5xx(4xxanswers still read as empty, andGET .../rowcountanswers500when the server's instance cannot count). Code that checks forNoneshould passsilent=True(get_data(silent=True),get_rowcount(silent=True),get_sync_time(silent=True),read(..., silent=True)), which also silences the warning and gives 4.1's results, or catch the exception. Actions keep returningSuccessTuples. - Null-like strings are text in text columns. In a column declared with a text dtype (
dtypes: {name: string}) and in a text index column (columns), the strings'None','NA','nan','NaT','NaTz',''and'<NA>'are written and read as text, andparams={'id': 'None'}(or'NA','nan','') matches that text instead ofIS NULL. Use PythonNoneforIS NULLand'_None'forIS NOT NULL, which work on every column. PostgreSQL syncs now store such strings as text in those columns where 4.1 storedNULL; existing rows are not changed. Undeclared columns keep 4.1's rule, and on Oracle, which stores''asNULL,''stays a null in every column. - Unknown keywords are reported.
pipe.get_data(),get_docs(),get_doc(),get_value()andget_backtrack_data()still ignore a keyword they do not know, but warn once per call with a suggestion:get_data() ignored the unknown keyword argument 'start' (did you mean 'begin'?);whereandfilterspoint toparams. Keywords an instance reads (a SQL instance'sbegin_add_minutes, a custom instance connector's own) never warn, and a warnings filter that turns warnings into errors (python -W error) makes it raise before the read. - Internal pipes are hidden.
mrsm.get_pipes()andfetch_pipes_keys()no longer return pipes whose connector keys aremrsm(a saved Pipeline's registration,Pipe('mrsm', 'pipeline', 'daily')) unlessconnector_keysincludes'mrsm', on every instance type. - Return types. New roots return Polars by default; upgraded roots retain pandas (see DataFrame library).
as_pandas=Truereturns pandas and aGeoDataFramewith.crswhen a column isgeometry[...];as_polars=Truereturns Polars, with WKB geometry and CRS metadata.as_iterator,as_chunksandas_docskeep their shapes.pipe.sync()returns aSyncResult, which is atuplesubclass equal to(success, message)with extra fields (inserted,updated,upserted,duration, ...).success, msg = pipe.sync()still unpacks. get_data(as_dask=True)raises. Dask is removed in 5.0: the call raises aTypeErrornaming this guide. Useas_iterator=True(chunks, lazily) oras_polars=True;pipe.sync()of a Dask DataFrame raises the same way (convert withddf.compute(), or pass a generator of chunks).- Result shapes of the native
Pipemethods.pipe.enforce_dtypes(<list, dict, tuple or text>)returns the columns' values, order and datetime dtype as before, but its pandas backends for untyped columns are the native engine's (int64,float64,string) where pandas' own inference gaveInt64orobject.pipe.filter_existing()keeps the pandas dtypes of the merge, except that a string column whose stored rows come back Arrow-backed from a custom instance isstringrather thanobject.pipe.fetch()from asql:source returns a generator of chunks with parsed datetimes (SQLite used to return text); a pluginfetch()that returns a list or dictionary comes back as one DataFrame, and a caller'schunk_hookon a plugin source runs in your process for every chunk the plugin yields.pipe.deduplicate(workers=N)runs the chunks concurrently only on thread-safe connectors, as before. - Time zone objects. On PostgreSQL, the datetimes in
get_data()frames andas_docscarrydatetime.timezone.utcwhere v4 (psycopg) hadZoneInfo('Etc/UTC'). The values compare equal, butstr(tz),repr(tz),tz.keyandisinstance(tz, ZoneInfo)differ. get_data()/enforce_dtypes()dtypes if your v4 never had polars. v4.1.4 chose its pandas dtypes by whetherpolarscould be imported. Every v4 extra that brings pandas (sql,api,polars,full) also brings polars, and then declared columns came back Arrow-backed:double[pyarrow],large_string[pyarrow], andtimestamp[ns, tz=UTC][pyarrow]for a declareddatetime64[ns, UTC]. A barepip install meerschaum==4.1.4(no extras) never installs polars and returned NumPyfloat64, pandasstringanddatetime64[us, UTC]instead. v5 always returns the first set, with or without polars; the values are the same. If you upgrade from a bare v4 install and your code needs the NumPy dtypes, cast after reading, for exampledf = df.astype({'dt': 'datetime64[us, UTC]', 'x': 'float64', 's': 'string'})(works on pandas 2 and 3).df.convert_dtypes(dtype_backend='numpy_nullable')is not a substitute: pandas 3 raises on Arrow-backed timestamps with a time zone.- New in 5.0:
mrsm.SyncResult, and everypipe.sync()input runs natively.mrsm.SyncResultis the public name of the tuple subclasspipe.sync()returns (success,message,inserted,updated,upserted,deleted,changed_intervals,duration,begin,end).pipe.sync()with no data (asql:,api:orvalkey:source, a plugin or a@make_connectorconnector), text, tuples, generators of chunks,force,workers,blocking=False,debug=True, plugin keyword arguments, temporary pipes and pipes withreferences or{{ }}templates all run in the Rust core. Dask DataFrames are not accepted any more (TypeError): call.compute()or pass a generator of chunks. A generator of chunks returned by a plugin'sfetch()is synced one chunk at a time, whateverworkerssays. A tuple is read as rows, as pandas does (v4 raisedTypeError).changed_intervalsholds the grid cells (one chunk interval wide) of the rows handed to the instance: a superset of the rows that actually changed.sql:instances onmssql,oracle,duckdband in-memory SQLite run through the Rust drivers too; theoracledriver needs the Oracle client library (libclntsh) installed, and an in-memory SQLite database (sql:memory) is one database file that thePipemethods andconnector.read()/exec()(the same Rust driver) share.pyarrowTables and RecordBatches sync natively; any other unsupported value raisesTypeErrornaming the accepted types. Unconstrained PostgreSQLNUMERICvalues keep the exact scale they were stored with. APIConnectorreturns anApiResponse.make_request()and the verb helpers (get,post,put,delete, ...) return anApiResponse, not arequests.Response, becauserequestsis no longer a dependency. It offers the common surface only:status_code,ok,text,content,headers(justContent-Type),url,json(),raise_for_status()(raising anOSError, asrequests.HTTPErroris) and truthiness. OtherResponsemembers (iter_content,cookies,elapsed, ...) do not exist, andmake_requesthonorsparams,json,data, onefilesentry andtimeoutonly. A refused or reset connection raises the builtinConnectionError(anOSError, asrequests.exceptions.ConnectionErrorwas) with the transport's message.connector.sessioncreates arequests.Sessionon access ifrequestsis installed, but the connector does not use it.get_actions()still returns a response whose.json()is the list of actions.ValkeyConnector.clientis a realvalkey.Valkeywhen that package is importable (Meerschaum never installs it), and otherwise a built-in client covering the commands Meerschaum itself uses (get,setwithex,pxandnx,delete,unlink,exists,incr,keys, the sorted-set, set, list and hash basics,flushdb,ping,execute_command); any other command raisesNotImplementedError. Install thevalkeypackage in your plugin's venv if you need the rest. Values it returns arebytes, as withvalkey.Valkey.- Raw SQL uses the Rust connector.
conn.read(),exec(),exec_queries(),value(),to_sql()and metadata helpers use native drivers without a SQLAlchemy dependency..engineand caller-provided SQLAlchemy connections remain optional (meerschaum[sql]); GeoPandasread_postgis(query, conn.engine)still works. See Native SQL and explicit engines. - DuckDB connections. Python DuckDB connectors share the public
duckdbdriver between Rust Pipe operations and SQLAlchemy, so both interfaces observe the same writes. Install thesql-duckdbextra (duckdb >= 1.5.6,duckdb-engine >= 0.17.0) and leaveMRSM_DUCKDB_LIBunset. An explicit override selects the native C API and refuses mixed SQLAlchemy access; unset it and restart to use both. Standalone / base-only installs continue to load the native shared library. Per-file connection settings must agree. Customcreator,module, anddo_connectrouting hooks are refused before connection access; standardconnect,checkout, and transaction events remain available. Prefer spawn for child processes. Fork children must exec a fresh interpreter before using DuckDB; the public driver cannot safely close inherited connections or finalize inherited worker threads during normal child interpreter shutdown. get_configis served by the Rust config registry.get_config,set_config,read_config,write_config,get_plugin_config,write_plugin_config,MRSM{}symlinks,MRSM_CONFIG/MRSM_PATCHandMRSM_<TYPE>_<LABEL>connectors behave as in v4, and the dictionariesget_configreturns are still live (mutate one and the next call sees it).meerschaum.config.configis that live dictionary; assigning a new dict to it (or callingset_config(cf)) replaces it. Config YAML values that JSON cannot hold (a2024-01-01date, bytes, very large integers) now load, substitute and write back unchanged instead of failing.- Constructing a
Pipenever touches the instance. Module-levelmrsm.Pipe(...)definitions import without a database, as today. Attributes are fetched on first access. - Partial-row syncs on non-upsert pipes (
pipe.sync([{'ts': ..., 'id': ..., 'status': 'done'}])) update the matching row without nulling other columns, as today. SQLConnectormethods that are gone.get_add_columns_queries,get_alter_columns_queries,get_to_sql_dtypeandcreate_pipe_table_from_dfbuilt the DDL of the Python sync; the Rust core builds the same statements insidepipe.sync()and they have no public replacement (read the table withconn.read()orpipe.get_columns_types()). The pipe, user and plugin methods ofSQLConnectormust be called on a real connector: v4 let tests call the underlying functions with stand-in objects asself.oracleneeds the client library. Without the Oracle client library (libclntsh, Oracle Instant Client) on the loader path, anoracleinstance fails with aRuntimeErrorthat names it; v4 usedpython-oracledb's thin mode, which needs none.- Removed behaviors.
system:experimental:join_fetch(undocumented, off by default) is ignored with a warning. The config is read and written by the native YAML codec only: PyYAML andruamel.yamlare never imported or installed. - Packages are never replaced behind your back. Meerschaum no longer installs
semver(versions are compared by the Rust core), and an installed package that is older than the minimum of its extra (apandasbelow 3) is reported with a warning (mrsm upgrade packages pandas) instead of being upgraded intovenvs/mrsm. A sync followed byget_data()on an installed environment attempts no installation.meerschaum.utils.misc.interval_strno longer needshumanfriendly. - Instances that cannot be built. As in v4, a pipe whose instance connector does not exist (
instance='sql:nope') raisesUnable to parse connector keys 'sql:nope'from every call that needs it (get_data,get_rowcount,sync, ...), and the message now says why:Connector 'sql:nope' is not defined. Add it to the config (mrsm bootstrap connectors), set MRSM_SQL_NOPE, or install the plugin that provides it.; no call answersNoneor0.mrsm.get_connector('sql:nope')(andapi:/valkey:) warns with that sentence and returnsNone, without a traceback or "Missing flavor". When the defaultsql:main, as shipped, cannot be reached, the error ends with the next step (start the database withmrsm stack up -d db, or useinstance='sql:local'). - New in 5.0 for scripts.
pipe.sync(df, begin=..., end=..., replace=True)replaces a window in one transaction (Replacing a window);pipe.get_rowcount(estimate=True)reads the database's estimate;pipe.verify_schema(apply=False, dry_run=False)plans (and applies) the table changes its dtypes need;pipe.get_sync_status()andmrsm.get_sync_statuses()read how syncs went;Pipe(hypercore=...)is accepted.mrsm.Pipeline('daily')loads a saved Pipeline (a string with|is a pipe reference),mrsm.get_pipelines()lists them,pipeline.save()returns aSuccessTupleand refuses to replace a different saved Pipeline withoutoverwrite=True,plan()registers nothing, andmrsm.get_runs(status=, pipeline=, pipe=, since=)filters the run history; see the Python section of Pipelines. -
Removed internal modules. Importing a module from the former Python core raises an
ImportErrorwhose message names the replacement. The commonly imported ones:Import v5 meerschaum.connectors.sql._pipes,_users,_plugins,_compress,_maintenance,_partition,_fetchPrivate. The public SQLConnectormethods are thin delegations to the Rust core with the v4 signatures and docstrings; the query builders and helpers behind them (index, partition and compression helpers, temporary-table bookkeeping,_instance.py) are removed.meerschaum.connectors.sql._sql,_create_engine,_uri,tablesKept ( read,exec,valueandto_sqluse native SQL;.engineand the SQLAlchemy table-building helpers require the SQLAlchemy extras).meerschaum.connectors.sql._users.valid_usernameKept at the same path as an SDK re-export. meerschaum.connectors.sql.tables.get_tablesKept at the same path; returns SQLAlchemy Tableobjects and requires the SQLAlchemy extra. Ordinary pipe operations and native catalog helpers do not call it.meerschaum.core.Pipe._*internalsRemoved. meerschaum.actions.<module>function importsmeerschaum.actions.actions['sync']and friends keep their names and dispatch to Rust.meerschaum.api.routes.*Removed. The routes are Rust. meerschaum.api.dash.callbacks.*,meerschaum.api.dash.pages.*Kept: the console is the Dash app of the Python package ( meerschaum[dash]), ported onto the faΓ§ade.meerschaum.utils.daemon.DaemonKept as a read and control faΓ§ade over the Rust job supervisor ( status,pid,pause,quit,kill, logs, properties,get_daemons(), ...). It no longer runs Python code:Daemon(target=<your callable>),run_daemon,daemon_actionanddaemon_entryraiseNotImplementedErrornamingJob, and the file-descriptor interception helpers are gone. Run work withmeerschaum.jobs.Job, which keepsstart,stop,pause,get_logs,monitor_logs.meerschaum.utils.sqlKept at the same path, with every documented helper: build_whereandsql_item_namedelegate to the Rust core, the rest (clean,dateadd_str,table_exists,get_sqlalchemy_table,get_table_cols_types,get_update_queries,get_create_table_queries,wrap_query_with_cte, ...) keep their v4 implementations. -
Kept SDK names (verified against real plugins, so implementers must keep them):
SuccessTuple,warn,error,info,dprint,pprint,print_tuple,print_options,attempt_import(withvenv=andlazy=),import_pandas,pip_install,Venv,Plugin(name).module,from_plugin_import,import_plugins,make_connector,make_action,add_plugin_argument,pre_sync_hook,post_sync_hook,prompt,yes_no,wget,round_time,select_pipes,string_to_dict,items_str,filter_keywords,filter_arguments,parse_df_datetimes,query_df,none_if_null,get_current_timestamp,serialize_bytes,deserialize_bytes_string,serialize_geometry,replace_pipes_syntax,STOP_EVENT,ROOT_DIR_PATHandmeerschaum.config.paths,get_plugin_config/write_plugin_config(including the no-argument form),UserandTokenwithhash_password,verify_password,_password_hash,_attributes,_user_id,Token(user, label, scopes, expiration).register()and.get_api_key().
DataFrame library¶
Fresh roots return Polars from pipe.get_data(), SQLConnector.read(), fetches, Pipeline frames and plugin hooks. On the first upgrade, roots from 4.x and earlier 5.0 candidates get dataframe: pandas written once to config/meerschaum.yaml, with a notice. That pin preserves existing scripts. To switch, set meerschaum.dataframe: polars (dataframe: polars inside meerschaum.yaml) or MRSM_DATAFRAME=polars; pandas is also accepted. Docker's fresh built-in roots use Polars; older mounted roots retain pandas.
Install meerschaum[polars] for Polars or meerschaum[pandas] for pandas. Use as_pandas=True or as_polars=True for one read; setting both is an error. Iterators and chunk lists contain the selected library's frames. enforce_dtypes() and filter_existing() preserve pandas or Polars input. Polars geometry is WKB with CRS metadata; as_pandas=True retains GeoDataFrames. Code using .iloc, .astype() or pandas index alignment should retain pandas or use Polars equivalents.
Custom instance connectors receive the selected library's frame in sync_pipe(pipe, df), as do chunk hooks. A connector or hook using pandas methods should retain the pandas setting or convert at its boundary with meerschaum.utils.dataframe.to_pandas(df). A Polars-only installation never needs pandas for native reads and writes. If an old root's configuration is read-only and cannot be pinned, the process keeps pandas and prints the setting to add.
Native SQL and explicit engines¶
SQLConnector.read, exec, exec_queries, value, to_sql, table_exists, get_table_cols_types and get_table_cols_indices use Rust on every SQL flavor without requiring meerschaum[sql]. pandas, Polars and Arrow inputs, bound parameters and chunk hooks remain accepted. .engine, caller-provided SQLAlchemy connections, and helpers that construct SQLAlchemy objects (get_tables, get_sqlalchemy_table) still require their SQLAlchemy extras. On SQLite and GeoPackage, use the native methods for a file that Meerschaum accesses in the same process: .engine and Python's sqlite3 load a separate SQLite library, and even closing a read connection there can release the native library's file locks.
exec() returns a NativeResult with the usual cursor methods (fetchall, fetchone, fetchmany, scalar, scalars, mappings, keys), rowcount and tuple rows with named access. It is not a SQLAlchemy CursorResult; lastrowid is None, and commit=False rolls back when the call finishes. Keep a caller-provided SQLAlchemy connection when you need to control a transaction across calls. exec_queries() executes its queries and hook queries in one transaction. read(..., index_col='id', as_pandas=True) now keeps that index; an unchunked read infers each column from the whole result rather than concatenating separately inferred frames. A connector's native_sql: false retains the SQLAlchemy path for compatibility; --debug identifies explicit fallbacks.
Teams, access and permissions¶
The first API account is an administrator. After that, self-registration is disabled unless api.permissions.registration.users: true is configured. Administrators add users under Users & Teams or with register users. Users without explicit scopes follow api.permissions.default_role (editor by default). Viewer reads; Editor adds, edits and syncs pipes, defines SQL and starts existing jobs; Operator also executes actions, uses the terminal and manages jobs. An explicit legacy api.permissions.actions.non_admin: true maps the default to Operator; --secure remains administrators-only for actions. Empty custom permissions grant nothing. Tokens are intersected with their owner's current permissions on each request.
Every user belongs to Everyone. Administrators create additional teams; managers maintain membership and pipe access for their team. A primary team is the default for new pipes and jobs. access and locks are now reserved pipe parameter keys: check existing custom uses before upgrading. Missing access keeps shared Everyone behavior. API, MCP and console visibility applies to pipes, saved Pipelines, runs, statuses, lineage, counts and jobs. Local database credentials are trusted. See Access and locks and Teams and access.
Permission lists stored before the new scopes keep their earlier grants: pipes:write also registers and defines SQL, and actions:execute also opens the terminal. New custom grids can grant pipes:register, pipes:define_sql and webterm:use separately. A connector's teams: [analytics] restricts its use through the API and console. Administrators add users with register users, edit roles with edit users, and manage teams with register/edit/delete teams; show users now prints a table (JSON objects with --nopretty).
Locks apply locally too, and cannot be bypassed by --force, -y or --allow-protected. Unlock with mrsm unlock pipes --locks sync or pipe.unlock('sync'). drop also refuses schema changes and rebuilds; clear refuses replaced windows. Locked engine members finish as locked without failing the run. Copies retain locks and receive fresh access; team renames/deletions update pipes and jobs. Direct SQL access is a trust boundary, so use separate database credentials for strict tenant isolation. Other users' email addresses are shown only to administrators or their team managers.
Jobs from earlier versions and jobs created locally remain Everyone's. Remote jobs are visible to their owner, team and administrators; temporary console runs are visible only to the owner, team managers and administrators. Jobs started by those jobs inherit their ownership. Deleting a user clears pipe ownership without deleting the pipes, and deleting a team moves its pipes and jobs to Everyone.
π Plugin authors¶
Keep your plugin interface. Declare packages your plugin imports instead of relying on packages v4 happened to install.
Fetch, sync, hooks, connectors, and dependencies
Data plugins (fetch, sync, register, setup), action plugins (@make_action, add_plugin_argument), hooks, library plugins, required, per-plugin venvs and plugins_dir lists are unchanged. The plugin is still a Python module in MRSM_PLUGINS_DIR; fetch may still return a DataFrame, a GeoDataFrame, a list of dicts, a dict of lists, a JSON string, or a generator of any of those.
What is different underneath, and when it matters:
- Plugins run in a worker process when the host is the
mrsmbinary (CLI, jobs, server). The worker is a normal Python interpreter with the SDK loaded;attempt_import(..., venv='myplugin')andVenvactivate venvs exactly as today. Module-level state in your plugin lives for the life of the worker, not of the command, which matches today's job behavior but not today's one-shot CLI behavior. Don't rely on module globals being fresh per action. - The working directory is not on
sys.path. Every Python process Meerschaum starts (workers, the API host, jobs, compose and pipeline steps,systemdunits) runs with-P, so ameerschaum.pyor ameerschaum/in your project directory can never replace the installed package. A plugin which imports a sibling module from the directory it is run in (not from the plugins directory) must move that module into the plugins directory or into a package; plugins are imported through the plugins directory only. v4's in-process jobs never had the working directory on the path either. @post_sync_hookand@pre_sync_hookreceive the same kwargs (success_tuple,sync_timestamp,sync_complete_timestamp,sync_duration, ...) and run in the worker after each native sync.- Plugin manifests. On install, setup and on any source change, Meerschaum records which decorators your plugin uses so that
--help, tab completion and the server can start without importing Python. If your plugin registers actions or pages conditionally (for example, only in production), the manifest sees whatever your module registers at import time with the config present at install time.mrsm reload(also spelledmrsm reload plugins) rebuilds the manifest unconditionally and recycles the Python workers; the manifest is also rebuilt automatically when a plugin file changes, and afterinstall,upgrade,uninstallandsetupof plugins. - Actions under
--loopand-s.mrsm my_action --loopnow works for any action: Meerschaum repeats it--min-secondsapart and calls it once per run withloop=False, so it no longer needs awhile Trueof its own, and the job records each run for job health. An action whose function names aloopparameter (4.1'sdef poll(loop=False, ...)) keeps 4.1's contract:--looppassesloop=Trueand the action repeats by itself (its job's health readsunknown). Remove the parameter and the loop to let Meerschaum repeat it. With-s, the job's scheduler runs the action once per fire. - Action names with several words.
@make_action(name='weather wait')(or@make_action('weather wait')) registersmrsm weather waitwhatever the function is called; it runs likedef weather_wait, andmrsm weather wait more wordspassesaction=['more', 'words']. A word may use hyphens, not underscores, and a name that collides with an existing action warns and keeps the existing one.mrsm helplists every plugin action (and v4-style<action>_<sub>subactions) under its words, andmrsm weather -hlistswaitas a subcommand. - Writes to protected connectors. A plugin that writes through a connector marked
protected: true(syncing into it,exec(),to_sql()) gets the refusal (PermissionErrorfromexec(), a failedSuccessTupleelsewhere) unless the command runs with--allow-protectedorMRSM_ALLOW_PROTECTED=1, or the code is insidewith meerschaum.connectors.allow_protected():. On such a connectorread()andvalue()accept only statements that read. - New helpers.
meerschaum.utils.cachecaches function results and values in Valkey or process memory, invalidated by a pipe's syncs (Caching), andmeerschaum.testingis a pytest plugin with fixtures for a temporary root, a SQLite instance, pipes, the API host and console pages (Testing Plugins). fetch(begin, end)windows in graph runs. A barepipe.sync()and a flatsync pipespass the samebegin/endas v4. Under--parents,--childrenormrsm.Pipeline, a child can be asked to backfill chunk-aligned windows that start earlier than v4'ssync time β backtrack(see Windows your plugin receives). A plugin that clears its window and recomputes it must be able to rebuild that whole window.- Custom connectors with
@make_connectorkeep working, includingIS_INSTANCE = Trueconnectors implementing the instance interface. A new optional pair,acquire_pipe_lease/release_pipe_lease, lets the engine coordinate across processes; without it, leases fall back to machine-local file locks with a one-time warning. -
Streaming connectors. The v4 pattern keeps working under
--listen(see Jobs). The recommended v5 form is explicit and gets engine-managed micro-batching, acknowledgement after commit and supervision:# v4 pattern (still works) # v5 (recommended) def sync(pipe, **kw): IS_STREAMING = True client.on_message = lambda m: pipe.sync( [parse(m)] def subscribe(pipe, sink, **kw): ) def on_message(m): client.connect(...) sink.push([parse(m)], token=m.mid) client.loop_start() client.on_message = on_message return True, "Subscribed." client.connect(...) client.loop_forever()sink.push()buffers; the engine flushes on idle, latency, row or byte triggers (parameters['stream']) and callssink.ack(tokens)only after the commit. For MQTT, use QoS 1 and a persistent session.
Dependencies and venvs¶
v4.1's shared mrsm venv quietly accumulated whatever any plugin asked for: attempt_import('x') defaults to venv='mrsm' and installs x there if missing. Real plugin sets were found calling attempt_import without venv= for pandas, dash, requests, plotly, dash_bootstrap_components, geopandas, PIL, pypdf, pyogrio, markdown and icalendar, none of which the plugins declared in required. Several of those were only present because Meerschaum's own [dash] or [sql] extras happened to install them.
In v5 the mrsm venv starts out holding only the extras you installed. To keep a plugin working:
-
Declare what you import. Every package your plugin uses goes in
required, including the ones v4.1 used to install for you:Plugins that don't declare a package still work:
attempt_importinstalls it on first use, as before. Butmrsm show pluginswarns about every undeclared package, andmrsm prune venvswill not know who needs it. -
attempt_import(...)keeps its signature and itsvenv='mrsm'default, because a DataFrame or Dash component created in one plugin's venv and handed to another has to come from the same library copy. Passvenv='<plugin>'for packages only your plugin uses, exactly as today. -
Prune. Every install
attempt_importperforms is attributed: the requester (a plugin, ormeerschaumfor an extra or core install) is recorded invenvs/<name>/.mrsm_requests.json.mrsm show venvslists every venv, its size and each package with its requester:mrsm prune venvs --dryshows what nothing still needs;mrsm prune venvs mrsm -yremoves it. A package is kept if Meerschaum requested it, if an installed plugin requested it or lists it inrequired, or if a kept package depends on it. Packages with no recorded requester are never removed.mrsm upgradeprints what a prune would remove, and v5 never deletes packages by itself. -
Pin what matters. With
dash,pandasand friends no longer pinned by Meerschaum, add lower bounds inrequiredfor anything your plugin is sensitive to. -
Reinstall requirements when changing Python versions. A plugin venv's packages belong to its Python minor version. Upgrading Python 3.13 to 3.14 creates a separate package directory; packages installed for 3.13 are not automatically available to 3.14. Run
mrsm install required <plugin>with the new interpreter before resuming its Pipes or Jobs. Reinstall the shared extras you use with that interpreter as well.mrsm doctorreports stale plugin environments. Keep the Python version fixed when testing the Meerschaum upgrade alone. -
MRSM_NO_AUTO_INSTALL=1now disables every implicit install, including the dependency prechecks that v4 ran anyway (v4 still downloadedsemverfor the version check even with the variable set, and the API importeduvat startup).attempt_import,is_installed, the version checks, theuv/pip/wheelbootstraps and the API host install nothing; when no version parser is available the check compares versions natively and, failing that, assumes the requirement is met. Installs you ask for (pip_install(...)from your own code,mrsm install packages,install required) still run.uvis only used if it is already onPATH. -
MCP tools registered with
meerschaum.mcp.tool(...)/resource(...)keep working. The Rust MCP server lists them alongside the built-in tools and forwards calls to your plugin with the caller's scopes.get_tools,get_resourcesandmeerschaum.mcp._registry.get_toolsstay for duplicate-registration guards.
π API plugins¶
Python routes still work through the API host. Middleware which intercepted core FastAPI routes needs review because those routes now run natively.
Routes, middleware, startup hooks, and sessions
@api_plugin functions still receive a FastAPI app and can add routes, middleware, StaticFiles mounts and background threads. meerschaum.api.endpoints, get_api_connector, get_cache_connector, production, debug, ScopedAuth, CurrentScopes, _oauth2.SECRET, _tokens.get_token_from_authorization, get_pipe and the models stay. api_plugin returns your function unchanged, so tests that call init_app(FastAPI()) against a bare app keep passing. from meerschaum.api import manager and Depends(manager) keep working too: manager is now a built-in JWT login manager with fastapi_login.LoginManager's call signature (user_loader(), optional, create_access_token(), ...), so neither fastapi-login nor python-jose is installed (tokens are validated with PyJWT against the same secret file as the Rust server). meerschaum.api.aiofiles, typing_extensions, importlib_metadata, packaging_version, multipart and uvicorn_workers are no longer re-exported; import what you use from your plugin (declare it in required).
Known difference: your @api_plugin routes work, but they are not listed in the server's /openapi.json, /docs or /redoc (v4 listed them because one FastAPI app served everything); the Rust server documents only its own routes.
New helpers. from meerschaum.api import session_user, optional_session_user, mount_static. session_user and optional_session_user are FastAPI dependencies for the user signed in to the web console (401, or None, without a session; a deleted user's session counts as none), so a route no longer reads the mrsm-session-id cookie itself. mount_static(app, '/my-plugin/files', directory) serves a folder safely: nothing outside it (.. in any spelling, symlinks pointing out), no hidden files (.env, .git), web content types and X-Content-Type-Options: nosniff; by default it needs a console session or an API token and sends browsers to the login page and back (auth=False serves anyone, html=True serves index.html). See Web Pages and Routes.
What changed: Meerschaum's own routes are served by the Rust server. Your routes are served by a Python API host that the Rust server starts when it sees a plugin needs it and reverse-proxies to. Every path the Rust server does not own, websockets included, reaches your app unchanged, so your public URLs do not move. Install meerschaum[api] wherever such plugins run (the Docker images already do), or leave MRSM_NO_AUTO_INSTALL unset and the host installs what it needs into the mrsm venv on its first start. With MRSM_NO_AUTO_INSTALL=1 and the extra missing, the host exits with an ImportError naming pip install "meerschaum[api]". The host runs inside the mrsm venv for its whole life, as v4's API server did.
Three patterns need attention because they assumed one process:
Middleware that wrapped core routes¶
Your @app.middleware('http') now sees only requests that reach your app: your own routes and anything the Rust server does not own. It no longer sees /login, /pipes/*, /mcp or the console. Audit each middleware:
| Middleware did | v5 |
|---|---|
Rate-limit POST /login |
Set api.permissions.rate_limits.login in config and delete the middleware. |
| Rate-limit your own routes | Keeps working, or set api.permissions.rate_limits with your routes' path prefix (/my-plugin: {requests: 30, seconds: 60}). |
| Log usage of your own routes | Keeps working unchanged. |
| Bust a cache after mutations on your own routes | Keeps working unchanged. |
| Add headers or auth checks to core routes | Not possible from a plugin. Put a reverse proxy (NGINX) in front, or open an issue describing the need. |
Stripping core routes by module name¶
# v4: hide every core route except /mcp
@app.on_event('startup')
def _strip():
app.router.routes[:] = [
r for r in app.router.routes
if not getattr(r.endpoint, '__module__', '').startswith('meerschaum.')
]
finds nothing to strip in the host, because the core routes are not in it. Use the existing allowlist instead; it is enforced by the Rust server by route group:
The allowlist is a list of fnmatch patterns over the core route groups: actions, connectors, index, jobs, misc, pipes, pipelines, plugins, tokens, users, version, engine, mcp and dash (the console's own pages). The default is ['*']. A core group that is not allowed is not registered, and its paths are not forwarded to the Python host.
An empty allowlist also blocks a plugin's /healthcheck replacement. For plugin-only deployments, use the public preset to retain native health checks while keeping administrative routes closed.
New in 5.0: api.permissions.routes.disabled explicitly disables matching route groups and overrides the allowlist. Native /login is enabled by default, independently of the allowlist; disable it explicitly with:
The disabled login route cannot reappear through host fallback. A namespaced plugin login such as /acme/login remains a separate route. Registering a FastAPI handler for /login does not replace the native route; exact core-route replacement is not supported. Custom Dash login layouts registered with web_page('login', ...) remain supported and are independent of login API routing. Verify your external endpoint surface after migrating Python route-stripping hooks.
Background threads and startup hooks¶
@app.on_event('startup') and threads started inside your @api_plugin run in the host process, which starts once per server start and is restarted on crash. Make startup idempotent.
Sessions from API routes¶
meerschaum.api.dash.sessions (set_session, get_user_from_session, get_username_from_session, delete_session, is_session_active, session_is_admin, set_session_cookie, EXPIRES_SECONDS) reads and writes the same session store as the console, keyed by the same mrsm-session-id cookie. Plugins that implement their own registration or login flows and mint console sessions keep working, with one rule v4's Valkey and in-memory stores did not have: a session ID is the UUID form the login uses (str(uuid.uuid4())). Any other value counts as no session for every reader (the console's callbacks, session_user, mount_static), delete_session() ignores it, and set_session() / update_session() write nothing for it; both now return whether they wrote (v4 returned None). For new routes, prefer the session_user dependency above.
ποΈ Web pages and Dash plugins¶
Existing Dash pages remain supported. Console callbacks require a session unless explicitly public. New pages can use the shared page contract.
Dash pages and the page contract
The web console is the Plotly Dash app of v4.1, ported onto the v5 faΓ§ade and served by the Python API host at the same /dash paths (/dash, /dash/pipes, /dash/jobs, /dash/login, ...). It needs pip install meerschaum[dash] (the full Docker image has it). The standalone binary (cargo install meerschaum, the rust image) has no Python in it: it serves the API, /docs, /engine/*, MCP and the web terminal, and /dash shows a pointer page. The api.permissions.routes.allowlist route groups hide console pages as in v4. What moved and what is new:
/dashis the Overview (the instance's pipes by sync status, connector and latest sync; its saved Pipelines by last-run status with a 24-hour run chart; the jobs that are running, stalled, failing or stopped; and the recent runs). The dashboard you knew, with its key filters, command builder and web terminal, is the Terminal page at/dash/terminal(also/dash/webterm), and a bookmark to/dashwith an action in its query (?action=...,?connector_keys=...) opens it.- A top navigation on every console page replaces the logo's page list and the dashboard's More menu: the logo opens the Overview, then Pipes, Pipelines, Jobs, Connectors, Plugins and Terminal, a Pages menu for plugin pages (grouped by
page_group), the instance selector and a user menu with Users & Teams, Tokens, Settings and Sign out. - The Pipes page is a table, and each pipe has its own page at
/dash/pipes/<connector>/<metric>[/<location>]with Overview, Data, Parameters, Definition, Lineage (its parents and children) and Runs tabs; editing happens in Edit mode, and destructive actions ask you to type the metric key. The cards remain behind the Terminal page's Pipes button, and/dash/pipes?...bookmarks keep working. New pipe (/dash/pipes/new) registers a pipe from the console. Preview and every sync of a SQL pipe read with the chosen connector's credentials, so whoever may register pipes can read what those connectors can; limitpipes:register/pipes:define_sqlgrants and connectorteamswhere that matters. SQL checks refuse internal and hidden targets, but strict isolation requires separate connectors and database users. - New pages: the read-only Connectors page (connection settings only, every other value masked; administrators only under
--secure) and thepipelinespages. The Jobs page creates, restarts and deletes jobs, the Users page is a table where administrators manage users (instead ofedit users/delete usersin the web terminal), and/dash/pluginsopens on the installed plugins, the repository listing being its Repository tab (?tab=repository)./dash/usersand/dash/pluginsneed a signed-in session. - Callbacks need a session. The console answers a callback only for a signed-in visitor (
401otherwise, and the browser goes to/dash/login?next=...), except the login and registration forms, the page router and the callbacks a plugin marks public; see the checklist below.api.dash.callbacks_require_session: falseturns the rule off for the whole console, and--no-authneeds no login at all.
There are two ways for a plugin to add pages.
Option A: keep your Dash pages¶
Nothing changes from v4.1. @dash_plugin receives the console's own dash_app, and @web_page(...) functions returning Dash layouts register pages in that same app at /dash/<path>. Core component IDs (session-store, mrsm-location, mrsm-theme-*) exist in the app, index_string and external_stylesheets apply to the whole console as before, and callbacks can target core pages by ID. meerschaum.api.dash.components.build_cards_grid, keys, pipes, graphs and meerschaum.api.dash.sessions stay available (api.dash.callbacks and pages are back as modules too). A plugin's assets/ folder is served at /dash/_plugin-assets/<plugin>/ and its CSS and JavaScript files are loaded by the console.
Checklist:
- Install
meerschaum[dash]wherever the server runs (and[api], which it includes). Without Dash importable the host logs one warning and/dashshows the pointer page; plugin Dash pages are not served. - Packages you declared stay yours.
meerschaum[dash]installsdash,dash-bootstrap-components,dash-ace,dash-extensionsanda2wsgi. It does not installdash-daq,Flask-Compress,terminadoortornadoany more (the terminal is the Rust server's): declaredash-daqin your plugin'srequiredif you use it. session-storeand logins work as in v4. Callbacks that wrotesession-storeto log a user in should still callmeerschaum.api.dash.sessions.set_session, which the console and the Rust web terminal both read, with a UUID session ID (see Sessions from API routes).- Pages anyone may open need public callbacks. Every console callback now needs a signed-in session, checked before it runs. A
@web_page(..., login_required=False)page whose callbacks are registered in a plain@dash_pluginfunction sends anonymous visitors to the login page as soon as one fires. Mark the callbacks anyone may run public, and make them distrust their inputs:@dash_plugin(public=True)marks every callback the function registers, and the new@web_callback(..., public=True)registers one at the top level of your plugin (it takes Dash'sOutput/Inputobjects or'id.property'strings, so the plugin need not import Dash). Or setapi.dash.callbacks_require_session: falseto keep v4's behavior for the whole console. Callbacks that checkedsession-storethemselves keep working. Flask routes a plugin adds todash_app.serverare not callbacks and are not guarded: move them to@api_pluginwithsession_userormount_static. Dash's opt-in MCP server (api:dash:mcp:enabled) needs a console session or an API token. - Console internals that moved.
meerschaum.api.dash.components.pages_offcanvas,build_pages_offcanvas_childrenandget_items_menuare gone with the logo's page list and the More menu.build_pages_navbar()builds the full navigation (with the instance selector unlessinstance_select=False),build_plugin_navbar()is the plain bar, andcomponents.navbar/instance_selectare built on access.meerschaum.api.dash.pages.dashboard.layoutis the Terminal page's layout. - The bar above your page, its theme and its head. A plugin's page keeps the plain bar (the logo, which now opens the Overview, and Sign out) above your layout as in v4;
@web_page(..., navbar=True)gives it the console's navigation andskip_navbar=Truestill removes the bar.@web_page(theme='light'), or the URL of any Bootstrap theme, styles one page,@web_page(head=[...])adds meta tags, stylesheets and scripts to its<head>, andadd_head(...)adds them to every page.@web_page(dark_theme=...)keeps working. See Web Pages and Routes. - Terminal Exec and background execution. Dashboard Exec validates the command with the server (
POST /execute/validate) and submits it once into the existing terminal iframe. Its output stays in that terminal. The shell confirms an empty prompt before submission, including with tmux and after refreshing the iframe. If a persisted tmux shell predates this readiness update, open a new terminal window once. Finish, erase, or cancel unfinished terminal input before submitting; reconnecting never replays a command automatically. Pipe management, Pipeline runs and "Follow logs" use background execution (POST /execute, thenGET /logs/<job>/stream). Plugins that reused the former background-output websocket should use those routes. The server enforces action permissions and returns a403naming a refused action (GET /execute/policylists the policy). - Windows. The host answers over loopback TCP with a per-start token, so the console and plugin pages load. Dashboard Exec uses the terminal; background workers still require Docker or WSL.
Option B: the page contract (recommended for new pages)¶
A v5 plugin page is a function that returns an HTML fragment. The console frames it (an iframe of the page with the console's navbar, theme and session) and serves it at /dash/<path>, the same for Python and Rust plugins. Charts are Plotly figure JSON: build the figure with plotly.py exactly as before, then hand the JSON to the page, which renders it with Plotly.js (the copy that ships with the plotly package). Interactivity is HTMX: an element requests a fragment from one of your routes and swaps it in.
Side by side, a page with a dropdown that re-draws a chart:
from meerschaum.plugins import dash_plugin, web_page
from dash import dcc, html, Input, Output
import plotly.graph_objects as go
@dash_plugin
def init_dash(dash_app):
@web_page('counts', login_required=True, page_group='Reports')
def counts_page():
return html.Div([
dcc.Dropdown(id='acme-site', options=site_options(), value='all'),
dcc.Graph(id='acme-counts-graph'),
])
@dash_app.callback(
Output('acme-counts-graph', 'figure'),
Input('acme-site', 'value'),
)
def update_graph(site):
df = counts_pipe.get_data(params={'site': site} if site != 'all' else None)
return go.Figure(go.Scatter(x=df['ts'], y=df['count']))
from html import escape
from meerschaum.plugins import web_page, api_plugin, figure
import plotly.graph_objects as go
def counts_figure(site):
df = counts_pipe.get_data(params={'site': site} if site != 'all' else None)
fig = go.Figure(go.Scatter(x=df['ts'], y=df['count']))
return figure(fig) # -> '<div data-mrsm-figure="{...plotly json...}"></div>'
@web_page('counts', login_required=True, page_group='Reports')
def counts_page():
options = ''.join(
f'<option value="{escape(v)}">{escape(label)}</option>'
for v, label in site_options()
)
return f'''
<select name="site" hx-get="/acme/counts/figure" hx-target="#acme-fig" hx-trigger="change">
{options}
</select>
<div id="acme-fig">{counts_figure('all')}</div>
'''
@api_plugin
def init_api(app):
@app.get('/acme/counts/figure', response_class=HTMLResponse)
def counts_figure_route(site: str = 'all'):
return counts_figure(site)
Notes on the contract:
- The figure code did not change. Only the transport did:
dcc.Graph(figure=fig)became adivwith adata-mrsm-figureattribute carrying the figure JSON.meerschaum.plugins.figure(fig)builds thatdivfrom aplotly.pyfigure (or a figuredictor JSON string), escaping the JSON for the attribute;figure_json(fig)returns just the compact JSON, byte for byte what a Rust plugin built with theplotlycrate would emit for the same chart. The console renders every suchdivon load and after each HTMX swap. - Maps:
<div data-mrsm-map='...'>renders GeoJSON or a layer spec with MapLibre GL JS, loaded on demand. This is the replacement for dash-leaflet. Maps need a browser with WebGL2 (MapLibre GL JS 6 dropped WebGL1, as did Plotly.js 4's map traces). - Plotly.js 4: the console draws figures with Plotly.js 4.1, so figures written for Plotly.js 2 or 3 are adjusted before they are drawn: a string
titlebecomestitle.text(andtitlefontbecomestitle.font), and the removedscattermapbox,choroplethmapboxanddensitymapboxtraces (andlayout.mapbox) are renamed toscattermap,choroplethmapanddensitymap(andlayout.map). Use the new names in new code;plotly.pyfigures already emittitle.text. Plotly.js 4 also changed a few defaults you may notice (layout.geo.fitboundsis'locations',hsv()colors are no longer accepted), and it turns on a "Share chart" button that uploads the figure to a Plotly cloud service: the console switches it off (showSendToCloud: false) unless a figure's ownconfigsets it. - Tables: return HTML tables; the console adds sorting. Editable grids are your concern.
- Login and session.
login_required=True(the default) is enforced by the console before your function runs: a request without an activemrsm-session-idsession gets a401. Your function is called with no arguments, so read per-request values (the query string, the user) from an@api_pluginroute that your HTMX elements call, as in the example above. An exception in the function answers500and prints the traceback in the host's output. - Theme. The
mrsm-theme-*CSS classes keep their names. Style against them and your fragment matches the console in both themes. - No CDN. Plotly.js, MapLibre GL JS and HTMX are served by Meerschaum itself. Don't add
<script src="https://cdn...">tags; they will be blocked by the console's content security policy and would not work on offline deployments anyway.
A function that returns a string (or a (html, status) pair) is a page-contract page; one that returns a Dash layout is a Dash page. The manifest records which, by calling the function once at install time. A page written this way moves to Rust without touching its chart, because the Rust plotly crate emits the same figure JSON (see Rust plugins).
π¦ Rust plugins¶
Rust plugins may be single files or crates using the native SDK. Python is only needed when a plugin uses the Python host.
Write a native plugin
v5 is still a Python package (pip install meerschaum). Most users keep writing Python plugins, and they work unchanged. Rust plugins are an option for the native performance path (the core, kernel and engine) and for a lean image: slim is the base wheel on a minimal Python, and a Rust plugin needs none of the extras.
New in v5. The quickest Rust plugin is a single .rs file in the plugins directory, which Meerschaum compiles on first use. A packaged Rust plugin is a cdylib built with the meerschaum-plugin crate, installed into MRSM_PLUGINS_DIR as <name>/plugin.toml plus the shared library, or as a Cargo crate (plugin.toml, Cargo.toml, src/) that Meerschaum builds itself into MRSM_ROOT_DIR/cache/native_build/ when cargo is on PATH (the prebuilt library is the only form that works on the slim image, which has no toolchain). It can implement fetch, sync, register, setup, actions, hooks, a source or instance connector, streaming subscribe, job executors, HTTP routes, MCP tools and console pages. Data crosses the boundary as Arrow record batches. Through host calls it reads and writes pipes (pipe.get_data(...), host::pipe(...), pipe.replace(...)), runs SQL (host::read_sql, host::exec_sql) and keeps its settings in the same plugins:<name> config section Python's get_plugin_config() reads (table of Python equivalents); from_plugin_import has no Rust counterpart, so port such a helper.
use meerschaum_plugin::prelude::*;
#[derive(Default)]
struct Acme;
impl Plugin for Acme {
/// The plugin's name and version are the Cargo package's.
fn manifest(&self) -> Value {
Manifest::new().fetch().into()
}
fn fetch(&self, request: &Request) -> Result<Option<Frame>> {
let rows = vec![json!({"ts": "2024-01-01T00:00:00Z", "id": 1, "value": 1.5})];
Frame::from_rows(rows, &[("ts", "datetime"), ("id", "int"), ("value", "float")]).map(Some)
}
}
meerschaum_plugin::export_plugin!(Acme);
A plugin directory may contain both a Rust library and a Python module; the Rust plugin replaces a Python plugin of the same name entirely, with no per-function fallback. Python plugins can depend on Rust plugins and vice versa through required. Start with the single-file Rust plugin guide; the advanced guide covers packaging, the SDK manifest, the C ABI, testing, and current limitations. A native plugin's console pages return the same HTML fragments and Plotly figure JSON as a Python page-contract page.
π’ Deployment¶
Use matching client/server releases and restart services after installing. Containers are upgraded by replacing their images while preserving mounted data.
Containers, TLS, services, and terminal sessions
Registration and terminal access
Create the first administrator before exposing a new instance. After that, self-registration is off unless api:permissions:registration:users: true is configured. New users default to Editor, which cannot execute arbitrary actions or open the terminal. Give trusted operators the Operator role; --secure further restricts actions, terminal use and console mutations to administrators. An explicitly configured legacy api:permissions:actions:non_admin: true selects Operator as the default unless you set default_role explicitly. Existing users keep explicit scope grants. --private requires authentication on endpoints; --no-auth removes authentication and still prints its own warning on a non-loopback bind.
- Docker.
bmeares/meerschaumandbmeares/meerschaum:apikeep their entrypoints,/meerschaum/plugins,/meerschaum/configand/meerschaum/config/plugins.json.docker cpof plugins into the container keeps working. The default image shipsmeerschaum[full], so API and Dash plugins work without further installs.bmeares/meerschaum:slimships only the base wheel and is the right base for Rust plugins or plugins that return dicts. As in v4, the images shipcurl(the stack's API healthcheck runscurl --fail http://localhost:<port>/healthcheck), and the full image shipsgitandssh(plugins may requiregit+https://...packages); the slim image hascurlbut nogit. - Stack API image. The default
apiservice runsbmeares/meerschaum:<version>-full(for examplebmeares/meerschaum:5.0.0-full: thefullimage of the same release as themrsmthat writes the compose file) instead of the floatingbmeares/meerschaum:apitag, so the server and the client always run the same version. A root withoutconfig/stack.yamlfollows the version on its own: the nextmrsm stack up -dafter an upgrade recreates only theapicontainer (volumes and database are kept). Astack.yamlwith adocker-compose.yamlsection keeps its ownapiimage: editservices:api:imagewithmrsm edit config stack. See the stack. - Platforms. Wheels are published for Linux x86_64 and arm64 (glibc
manylinux_2_28and muslmusllinux_1_2, so Alpine works), macOS arm64 and x86_64, and Windows x86_64. The standalone binaries are attached for Linux x86_64 and arm64, macOS arm64 and x86_64 and Windows x86_64; the macOS ones are not signed (xattr -d com.apple.quarantine <file>after a browser download, see Setup). The Docker images are multi-architecture (linux/amd64,linux/arm64). - Stack database setup. The default
dbservice mounts<root>/db/initdb/create-extensions.sqlas a single file,/docker-entrypoint-initdb.d/100_meerschaum.sql, instead of mounting the<root>/db/initdbdirectory over the image's whole setup directory, so the image's own setup runs again on a new volume (TIMESCALEDB_TELEMETRY: 'off'takes effect,timescaledb-tunetunes the server,template1gets TimescaleDB). The script v5 writes creates each extension only when the image ships it (another PostgreSQL image no longer aborts the first start) and preloadspg_stat_statements. The healthcheck ispg_isready -h 127.0.0.1 -p 5432 ...with a 300 sstart_period, sodbis healthy only once the setup is done (a passing check turns it healthy at once; the long start period keeps a slow first setup, e.g. on a Raspberry Pi, from abortingstack up), and Grafana's datasource says PostgreSQL 18 (postgresVersion: 1800). See The Database Setup. - Existing stacks.
mrsm stackregenerates the compose file from thestackconfig on every run. Without<root>/config/stack.yaml(v4'smrsm stacknever writes one; it exists only if you created it, e.g. withmrsm edit config stack), your stack takes the new defaults: the nextmrsm stack up -drecreates thedbcontainer once (a short restart; the data volume is kept and nothing in the database changes, since Docker runs the setup only on an empty volume). Any extra scripts you put in<root>/db/initdb/are no longer mounted, so they would not run for a future fresh volume: move them intocreate-extensions.sql, or add your own mount instack.yaml. With astack.yaml, itsdocker-compose.yamlsection is used as it is (v5's defaults fill in only a key that is missing entirely, as in v4), so its mount, healthcheck and image stay and the container is not recreated. To take the new compose defaults, edit thedbservice withmrsm edit config stack(the mount, thehealthchecktest andstart_period), or delete thedocker-compose.yamlkey fromstack.yaml(your otherstackkeys stay) and runmrsm stackagain. An existingcreate-extensions.sqlis never rewritten: before creating a NEW database volume, delete it (or merge your changes into v5's) so thatmrsm stackwrites v5's script. Update an existing database's extensions and settings withmrsm upgrade extensions(Upgrading the stack). - Bare metal. Replace
pip install --user meerschaum==4.1.4withpip install --user 'meerschaum[full]>=5,<6'(or the extras you need). Themrsmon yourPATHis now a console script that runs the native CLI inside themeerschaumwheel (there is no separate binary package). Jobs andsystemdunits runpython -m meerschaum, so they work from the same install. - HTTPS.
mrsm start api --keyfile privkey.pem --certfile fullchain.pemserves HTTPS natively, including the console, web terminal, MCP and event streams (HTTP/2 and HTTP/1.1). Configureapi.server.ssl_keyfile/ssl_certfile, or keep a TLS reverse proxy. Both files are required and validated before startup; renewals reload without a restart (30-second polling or SIGHUP). Without a pair the server serves plain HTTP. Legacyapi.uvicorn.ssl_*keys are read with a notice. Under HTTPS, SIGHUP reloads certificates rather than stopping the server; use SIGTERM or Ctrl-C to stop it. Plain HTTP to the TLS port answers/healthcheckand redirects other paths with308. - A public API.
mrsm start api --public(orapi:permissions:preset: public) is for a server that exists to serve a plugin's public routes and pages: it serves only/login,/healthcheck,/version, plugin routes and plugin pages, turns off the console's own pages, the web terminal, actions,/execute, user registration and the API docs, and says so at startup. Route groups you allowlist are served on top. See The public preset. - Permissions fail closed. When the instance that holds your users is briefly unreachable, a login or an API-key request now gets
503 Could not read your permissions...(retry it) where 4.1 granted every scope; the console's permission checks andregister tokens/edit tokensrefuse the same way. On Valkey instances,ValkeyConnector.get_user_id(),get_user_attributes(),get_user_password_hash()andget_user_type()raiseConnectionErrorwhen the read fails (a user who does not exist is stillNone): catch it where you treatedNoneas "not registered". - MCP SQL reads over HTTP reject internal names and SQL-text functions. Queries cannot reach internal
mrsm_*/_mrsm_*tables through quoted names, string literals or escaped identifiers. Functions that run SQL text or read a table named in text (such asquery_to_xml,dblink,crosstab,xmlqueryor DuckDB'squery) are refused even when your query is harmless. Use local stdio MCP or direct SQL for these trusted operations. Earlier versions could expose internal records through those queries; operators who exposed HTTP MCP should treat password hashes as potentially read. start api -p 8224 --host 127.0.0.1 -i sql:main --secureand friends are unchanged.- Webterm is served by the Rust API server itself (no Tornado/terminado process, no separate
start webterm):/webterm/<session id>and its websocket keep their paths and the terminado message format, the Dash console embeds the terminal at/dash/webtermand on the dashboard in an iframe (xterm.js 6.0.0 with the fit and unicode11 add-ons, served from/webterm/assets/, plus the mobile extra-key row), and the shell ismrsmon the server's instance. It keeps its tmux session model (api.webterm.tmux.enabled,api.webterm.tmux.session_suffix): each user's terminal lives in the tmux session<user><session_suffix>, so a reload or a second tab reattaches to it, and closing a tab never kills the session. Withouttmux, each tab gets its own shell. Those sessions now run on a tmux server of their own (a private socket under the Meerschaum root), started with Meerschaum's tmux configuration (the session's windows as tabs on the terminal's first row, mouse support, a long history), so they no longer show up in, inherit the settings of or die with your own tmux; the API stops that server when it stops, if it started it. After upgrading, the console opens a fresh session there: sessions an older server left on your default tmux server stay until you close them (tmux -S <old-socket> list-sessions,tmux -S <old-socket> kill-session -t <user>_mrsm). To keep your own tmux settings in the web terminal, setapi:webterm:tmux:config: ~/.tmux.conf.--no-webtermand--no-authstill turn it off. The terminal websocket refuses cross-origin requests: a reverse proxy in front of the API must pass the originalHost(or setX-Forwarded-Host) or the terminal answers 403. The terminal requireswebterm:use(Operator or admin by default); Editors cannot open it. With--secureorapi.permissions.actions.non_admin: false, only administrators may open a terminal. A second login no longer fails, and the shell echoes. start guiopens apywebviewwindow (themeerschaum[gui]extra) around the console at/dash/instead of around the Tornado terminal:mrsmstarts a localstart api --no-authfor it (bound to127.0.0.1only, on a free port) and stops it when the window closes. β οΈ While the window is open that console is unauthenticated: any process on the same machine can reach it on that loopback port, so do not usestart guion a shared machine.api startis an alias ofstart api.login,os,sql,stack,bootstrap, the connector, token and cache actions andedit config --rollbackkeep their prompts, messages and exit codes and no longer need a Python interpreter.- Plugin, package and venv actions run in
mrsm.uninstall plugins|packages,delete plugins|venvs,edit plugins,show packages,verify packages|plugins|venvs,bootstrap plugins,upgrade meerschaum|packages|pluginsandregister pluginskeep their prompts, messages and exit codes. They are orchestrated bymrsmitself:show packages,delete plugins|venvs,uninstall plugins,register plugins,bootstrap pluginsandedit pluginsneed no Python interpreter,verify pluginsstarts the Python worker to import each Python plugin, and the actions that install or remove packages (install|uninstall|upgrade packages, Python plugin upgrades,verify packages) runuvorpipas child processes against the interpreter inMRSM_PYTHON(elsepython3).verify packagesno longer imports every package to read its version: it reads the versions from the installed*.dist-infoin themrsmenvironment and repairs an old one, and a package found in the host interpreter is counted but never changed.edit plugins,bootstrap plugins,edit definitionandedit configopen$VISUAL, else$EDITOR, elsevi(v4 installedpyvimwhen none was set), and say so when the editor cannot be started.upgrade meerschaumuses the installation owner described above; it never infers that owner fromMRSM_PYTHONor a guessedpython3, and adds no--break-system-packagesunless you pass it. Plugin names may not contain path separators or.., start with., or bemrsm, anddelete venvstakes plain directory names only. Installing a plugin archive refuses symlink and hard-link members and writes only under the plugin's directory.mrsm start workerandstart|restart daemonsare not used in v5: actions run natively, so there is no CLI daemon to feed (show daemonsanswers that none are running).MRSM_NO_AUTO_INSTALL=1disables implicit installs only;upgrade,installanduninstallalways run. - The Python package must fit the program. The Python worker and the API host check the
meerschaumpackage they would import before importing it, so a stale copy cannot run its start-up (v4 installs packages whateverMRSM_NO_AUTO_INSTALLsays). A package of another major version, one that looks like v4 (noworkermodule), or one speaking another worker protocol is refused with one message (nothing is imported or installed; the message names the copy and the fix, andmrsm doctorshows it); a different release of the same major version starts and warns once, naming both versions. This matters whenmrsm(cargo install meerschaum, a release binary, the Docker image) and the Python package come from different installs: keep them on the same release, or pointMRSM_PYTHONat an interpreter that has the matching one. This is checked once per interpreter before a worker starts (a missingmeerschaum, a stale one or an interpreter that cannot start fails in well under a second with the message above, not after a 60 s handshake), and a worker that never connects is stopped after 60 s (MRSM_WORKER_STARTUP_TIMEOUT; a timeout alone is remembered for 10 s, never longer). - Logs. The Rust server's logs and the API host's output are interleaved in the same stream (the API job's logs under
mrsm start api -d). There is no separate host log and no filter in 5.0; the host's own lines (The API host exited ...; restarting,The plugin manifest changed; restarting the API host.) come from the server. The host is started only when an installed plugin needs it and restarted with backoff if it exits.
β¨ New in 5.0¶
See the concise v5 changelog for Pipelines, metrics, teams, native plugins, and console changes.
New capabilities
- The Syncing Engine and Pipelines:
mrsm sync pipes --parents [N] --children [N] --resume <run_id>,mrsm plan pipes(dry run),mrsm show graph,mrsm show runs/show run <id>,pipe.sync(parents=, children=),mrsm.Pipeline, the dirty-range ledger and run history on your instance, and the/dash/pipelinesconsole page. Pipe parametersschedule,stream,retryandmin_seconds(reference). - Continuous mode:
--loop(v4's laps, plus engine runs when the selection has edges) and--listenwithIS_STREAMINGconnectors, micro-batching and acknowledge-after-commit (Continuous Mode). - Saved Pipelines in the background:
mrsm start pipeline NAME/stop pipeline NAME, and from Pythonmrsm.Pipeline('NAME'),mrsm.get_pipelines(),pipeline.schedule(...)andmrsm.get_runs(pipeline=..., since=...)(Pipelines). - Per-pipe sync status:
pipe.get_sync_status(),mrsm.get_sync_statuses(),GET /engine/sync-status, and the failing pipes at the end ofshow pipes(Sync status). - Replacing a window:
pipe.sync(df, begin=..., end=..., replace=True),fetch: {replace: true}andsync pipes --replace(Replacing a window). mrsm verify schemaandpipe.verify_schema(): bring a table in line with its pipe's dtypes, asking first (verify schema). Row estimates:pipe.get_rowcount(estimate=True).- Protected connectors:
protected: true,--allow-protected,MRSM_ALLOW_PROTECTEDandmeerschaum.connectors.allow_protected()(Protected Connectors). --loopfor any action, and job health inshow jobs,job.healthand the console (Job health).- The web console's Overview, Pipes table and pipe pages, New pipe, Connectors page, New job, user management and installed plugins (Web Console).
- HTTP and MCP:
GET /pipelines/{name},POST /pipelines/{name}/plan,POST /pipelines/{name}/sync,GET /engine/runs/{run_id}and run filters (pipeline,pipe,status,since,limit),GET .../rowcount?estimate=true, one response shape for the 5.0 routes (Pipelines, Runs and Actions over HTTP), and the MCP toolslist_pipelines,plan_pipeline,sync_pipeline,list_runs,get_run,list_jobs,get_job_logsandlist_connectors(MCP tools). - The public preset (
api.permissions.preset: public,start api --public) and path-prefix rate limits (Permissions and Rate Limits), and the web terminal's own tmux server (api.webterm.tmux.config). - Plugin SDK:
@web_callback,@dash_plugin(public=True),@web_page(theme=, head=, navbar=)andadd_head(),session_user/optional_session_user/mount_static(Web Pages and Routes),@make_action(name='weather wait'),meerschaum.utils.cache(Caching) and themeerschaum.testingpytest plugin (Testing Plugins). - Rust plugin host calls and the
Manifestbuilder (Talking to Meerschaum). NO_COLORandFORCE_COLOR, andmrsm help(Getting help).mrsm.SyncResult: the tuple subclasspipe.sync()returns, withinserted,updated,upserted,deleted,changed_intervals,duration,beginandend.- Rust plugins, prebuilt or as source crates, replacing a Python plugin of the same name (Rust plugins), and the page contract for console pages (
meerschaum.plugins.figure(),data-mrsm-figure,data-mrsm-map). - Engine settings under
system.engine.*(workers,python_workers,python_worker_max_calls,python_worker_max_memory_mb,python_worker_idle_seconds,limits.connectors,limits.instances,auto_rebuild,implicit_edges,inclusive_cap,flat_ledger,events,persist_runs,state_flush_ms,run_history_days,debounce_seconds,chunk_pipelining,default_duration_seconds; Engine State) andapi.permissions.rate_limitsandapi.permissions.log_streams(max,per_user). - Packaging: one dependency-free
meerschaumwheel with extras (itsmrsmconsole script runs the native CLI),cargo install meerschaumfor a standalone binary, and thebmeares/meerschaum:slimimage. - Venv accounting:
mrsm show venvs,mrsm prune venvs, and attribution of every package installed byattempt_import. - Leases: two jobs never sync the same pipe at once (
mrsm_pipe_leases), with optionalacquire_pipe_lease/release_pipe_leaseon custom instance connectors. mrsm upgrade extensions: updates a PostgreSQL instance's extensions (TimescaleDB, PostGIS, the Toolkit, ...) to the versions its server ships after an image upgrade, and on the stack's database appliesTIMESCALEDB_TELEMETRY: 'off'and offers to preloadpg_stat_statements;mrsm stack up,mrsm start apiandmrsm upgrade mrsmpoint to it when an extension lags (Upgrading the stack).mrsm upgrade postgres: moves the stack's database to another PostgreSQL major (e.g. a stack whosestack.yamlpinstimescale/timescaledb-ha:pg17topg18): a full backup, then a restore into a new volume that is verified table by table before the stack switches to it; the old volume, the backup and a byte-for-byte copy ofstack.yamlare kept, andmrsm upgrade postgres rollbackgoes back (Upgrading the stack).- PostgreSQL startup safety:
mrsm stack uprefuses a known image whose PostgreSQL major differs from an existing named volume'sPG_VERSION. Pin the old image, start the old database, then runmrsm upgrade postgres <major>.stack up --forcebypasses this check; interrupted upgrades and restores offer recovery before another run (Upgrading the stack). mrsm backup postgres/mrsm restore postgres: an online, checksummed backup of the stack's database (nothing is stopped;mrsm backup postgres -s 'daily' -dschedules it,system:backups:postgres:keepkeeps the newest N), and its restore into a new volume, verified against the backup's manifest before the stack switches to it (mrsm restore postgres rollbackgoes back) (Backing up and restoring).- Shell completions:
mrsm install completions [bash|zsh|fish|powershell]writes a completion script formrsmandmeerschaum(actions, subactions, flags, connector keys, jobs, plugins, files);mrsm show completions <shell>prints it andmrsm uninstall completionsremoves it (Shell Completions). mrsm doctor: a read-only health check that says what is wrong with an installation and how to fix it: the version and worker interpreter, a stalemeerschaumcopy shadowing the right one, writable directories and free space (a full/tmpincluded), the config files, the instance and its tables, DuckDB and the Oracle client, the Python extras your plugins need, stale plugin virtual environments, failed jobs, the Docker stack and the PostgreSQL extensions. Every WARN or FAIL names its fix,--noprettyprints one JSON document for a bug report, and the result fails only when a check FAILs (Troubleshooting).
π§ͺ How to upgrade safely¶
- Try v5 with copied configuration and a disposable instance. Create a scratch root, for example
mkdir -p ~/.cache/mrsm-v5-review, and setMRSM_ROOT_DIRandMRSM_PLUGINS_DIRexplicitly. Point copied connectors at a test database or a restored copy of your data before syncing. A separate root alone does not isolate database writes. Engine runs add internalmrsm_engine_*tables under_mrsm_internal(on PostgreSQL and MSSQL; on MySQL, MariaDB, SQLite, DuckDB and Oracle beside your instance tables). - Lint your compose files:
mrsm compose explainnow warns about unresolved{{ Pipe() }}references,dtype:typos (fordtypes:) and string-typedtags. It never changes behavior. - Dry-run the engine:
mrsm plan pipes -t <tag>prints what an engine run would do without moving data. - Import every plugin:
mrsm show pluginson v5 imports each plugin and reportsImportErrors from removed internals, with the replacement named in the message. - Check scripts that read CLI output. Every option keeps its spelling, arity and type (report any difference as a bug), but
mrsm -hhas a new layout, piped output carries no ANSI codes,copy pipesexits1when a pipe fails, andshow pipesmay end with lines about failing pipes. Parse--noprettyoutput where you can. - Protect what you must not write. Mark the connectors of databases a trial must never change
protected: true: every write through them is refused until you pass--allow-protected, while reads keep working. - Start the API and open the console. If you have
@api_pluginor@dash_pluginplugins, confirm each plugin route answers (the host starts on demand; its restarts are logged by the server) and walk through each plugin page, signed out too if a page is public (its callbacks must be marked public). - Keep 4.1 around in a separate venv for a release cycle. Both versions read the same instance and the same root directory layout, so switching back is a
PATHchange. Upsert tables 5.0 creates keep 4.1's unique key, so 4.1 can still upsert into them (on PostgreSQL 15 and later that key isNULLS NOT DISTINCT, so 4.1 also updates rows withNULLkeys in place there; on SQLite and older PostgreSQL it inserts them again, as it always did). Tables of in-place SQLite pipes that 5.0 creates have typed computed columns, which a 4.1 process syncing into them in place converts toTEXT. Jobs that moved to systemd stay there: setmeerschaum:executor: localbefore going back if you want them local. - Clean up what 4.1 duplicated. Re-syncs of in-place SQLite rollups and upserts with
NULLkeys no longer duplicate rows, but the copies 4.1 made remain:mrsm deduplicate pipes -c ...removes them.
ποΈ Removed in 5.0, at a glance¶
Check these replacements if your code uses an older internal module or removed command.
Removed APIs and their replacements
| Removed | Replacement |
|---|---|
The Python core (connectors/sql/_*.py, core/Pipe/_*.py, actions/*, api/routes/*, api/dash/callbacks/*, utils/daemon (the Daemon faΓ§ade stays)) |
The Rust core; the faΓ§ade keeps the public API |
--engine laps |
--loop |
pyoxidizer builds, the portable archive |
the meerschaum wheel, release binaries, cargo install meerschaum |
| Plugin middleware over core routes | api.permissions.rate_limits, api.permissions.routes.allowlist, a reverse proxy |
| Python 3.10 | 3.11+ |
meerschaum.utils.packages.dash (the lazily imported dash module) |
Declare dash in your plugin's required and call attempt_import('dash') |
SQLConnector.get_add_columns_queries, get_alter_columns_queries, get_to_sql_dtype, create_pipe_table_from_df |
None: the Rust core builds the DDL during pipe.sync() |
meerschaum.utils.daemon: Daemon(target=...), run_daemon, daemon_action, daemon_entry, FileDescriptorInterceptor, and the lock, pickle and file-descriptor members of Daemon, RotatingFile and StdinFile; SystemdExecutor.get_job_process |
meerschaum.jobs.Job (jobs run in the Rust supervisor) |
meerschaum.actions.<module> (all 37 action modules; the functions live on in meerschaum.actions.actions), meerschaum.api.routes.*, api._events / _websockets / _chunks, meerschaum.api.dash.{websockets,sync} (the console's job and webterm callbacks use the server's /execute, /logs/<job>/stream and the Rust terminal), meerschaum._internal.term, _internal.cli, _internal.shell.{ShellCompleter,ValidAutoSuggest,updates}, _internal.arguments._parser.{parse_help,parse_version} |
meerschaum.actions.actions[<name>], meerschaum.entry([...]) and the mrsm binary (MRSM_BIN overrides it); get_completer() returns None for the standard actions and entry() ignores _use_cli_daemon / _session_id |
python-oracledb thin mode for oracle instances |
The Oracle client library (libclntsh) |
system:experimental:join_fetch |
Ignored with a warning |
PyYAML / ruamel.yaml for the config |
The native YAML codec (no package) |
api.uvicorn.* |
api.server.* (old keys read with a notice) |
meerschaum.api.dash.components.pages_offcanvas, build_pages_offcanvas_children, get_items_menu (the logo's page list and the More menu) |
The console's top navigation (build_pages_navbar()) |
start pipeline <flags> -P {...} as the way to write a chain (still runs) |
a + b : <flags> |
Meerschaum-installed dask, rich, dash-ace and the other packages listed under Installing |
Declare them in your plugin's required |