deployment - mvx-lang/mvx GitHub Wiki
The default. Each account directory carries its own local database —
mvxdata.sqlite, or mvxdata.lmdb for an lmdb account; concurrent
sessions on one host share it safely. Directory files are plain
directories. Nothing to operate.
A local database can live outside the account, which is what a container needs when the image is read-only and the data belongs on a mounted volume. That is a connection profile — see Named connections below.
An embedded database must never live on a network filesystem, so an account
reached from several hosts puts its files on a server built for it:
postgres, or mysql or mongo.
> SET-CONNECTION salesdb driver=postgres address=mvxdb:5432 dbname=mvx user=app
> CREATE-FILE ORDERS USING @salesdb
The committed BINDINGS record names only the profile (ORDERS @salesdb),
so it carries no address and no password; the profile is local and private.
Moving an existing file is CONVERT-FILE — records, dictionary and all —
and a whole account is CONVERT-ACCOUNT:
> CONVERT-FILE ORDERS postgres
> CONVERT-ACCOUNT postgres
There used to be a daemon of our own.
mvx-lmdbd, reached through anlmdbnetdriver, served LMDB over a socket. Both were removed (mvx#327) after a deprecation in v0.6.0. The reason was pushdown:lmdbnetsupplied none ofselect_where,select_order,count_where,sum_where, join or explain, so everyWITHstreamed the whole id list to the verb and filtered it there — and unlike embeddedlmdbit paid network latency to do it.postgresanswers those in SQL, holds record locks across sessions, and is not ours to maintain.If you have an account on it, the last release that can read it is v0.6.0. Move the files from there with
CONVERT-ACCOUNT postgres CONNECTION @salesdbbefore upgrading.$MVXDAEMONis no longer read: an account that still sets it is simply unbound and takes the account's default.
A file's backend is named at creation:
> CREATE-FILE ORDERS the account's default (sqlite)
> CREATE-FILE ARCHIVE DIR directory (git-native source)
> CREATE-FILE SHARED USING postgres a shared SQL database
> CREATE-FILE LOCAL USING lmdb a local key/value store
> CREATE-FILE ORDERS USING @salesdb a named connection (below)
USING <driver> {connection} binds the file to a storage driver — sqlite,
lmdb, dir, postgres, mysql or mongo — recording it in the account's
BINDINGS record and creating the file through that driver. DELETE-FILE
removes the file and its binding; LISTF shows each file's backend by name.
Different files may use different drivers and connections, so one account can
mix a local database, files on several servers and directory files — and one
program reads them all through ordinary OPEN/READ/WRITE.
The binding is a plain, hand-editable record: SPEC driver {params} per line
(* as the spec binds every file; params is the driver's connection string,
or @name for a profile). A line naming a file and no driver means the
account's default. Binding is resolution only; existing data does not move
when it changes.
To actually move a file to a different backend, use CONVERT-FILE:
> CONVERT-FILE ORDERS dir hash file -> directory file
> CONVERT-FILE ORDERS lmdb directory file -> hash file
> CONVERT-FILE ORDERS postgres local -> shared SQL
CONVERT-FILE file newtype {connection} re-keys the file's records —
and its dictionary — into the new backend verbatim, so a hash file
round-trips to a directory file and back without loss, and the %FILE%
control record is restamped to the new type.
Moving every file one CONVERT-FILE at a time invites missing one, which is
a poor way to retire a backend. CONVERT-ACCOUNT does the account:
> CONVERT-ACCOUNT sqlite everything not already there
> CONVERT-ACCOUNT postgres FROM lmdb only what is on one backend
> CONVERT-ACCOUNT postgres CONNECTION @sales with a connection profile
> CONVERT-ACCOUNT sqlite LISTONLY print the plan, change nothing
FROM is the retirement case — move what is on the backend being retired,
leave the rest alone. LISTONLY prints the plan and touches nothing, which is how to find
out what a conversion would do rather than discovering it afterwards.
Three rules are worth knowing before you run it:
-
A directory file is skipped unless you write
FROM dir. The point of adirfile is that its records are OS files a person can read andgitcan diff; converting one to a hash backend destroys exactly the property it was created for. So the obvious command leaves yourBPalone. -
CATALOGis never converted, not even byFROM dir. The shell reaches a cataloged verb by path — a VOC record saysCATALOG/CREATE-FILEand the shell execs it — so converting it would stop every verb in the account from running. -
VOCgoes last. Resolution starts there, so it is the one file whose move changes where everything else is looked up.
There is no rollback across an account. Each CONVERT-FILE is individually
atomic — it stages into a temp file, then replaces — so a failure part-way
leaves the earlier files converted and the later ones untouched, and the report
names what did not make it. If the first file fails, nothing has been
converted and the run stops there rather than repeating the same error for
every file: a wrong target fails the same way for all of them.
What the daemon guarantees:
-
Single lock authority.
READUlocks are granted by the daemon and leased to the connection: a client that dies withoutRELEASEloses its locks the moment the connection drops. No orphaned locks from killed pods. - Index atomicity. A record write and its index updates commit in one daemon-side transaction.
-
Snapshot selects.
SELECTmaterialises the id list before sending; a slow client never pins a read transaction.
Costs, accepted deliberately: the daemon is a single point of failure, the single-writer throughput ceiling remains, and replication/HA is not provided. Protocol integers are host-order — keep daemon and clients on the same architecture for now.
A profile's namespace is what keeps two accounts on one database apart.
Each backend spends it as its own native partition: postgres as the
schema, mongo as the database. Same-named files in different
namespaces are fully isolated — two accounts can each have their own private
ORDERS.
-
It defaults to the account's own name (the basename of the account directory), so pointing two accounts at one server isolates them with no configuration at all.
-
Sharing is explicit — the Q-pointer analog. Name the home namespace in the profile, and any account that names
SALESreadsSALES's data:> SET-CONNECTION sales driver=postgres address=mvxdb:5432 dbname=mvx namespace=SALES > CREATE-FILE ORDERS USING @sales -
Dictionaries are per-account views. A shared data file's dictionary (and its
%FILE%/%INDEXES%control records) lives with the account that binds it, so different accounts can hold different column definitions over the same shared data. Index data is shared; index definitions are local. -
Locking is the server's. A
READUon a postgres file takes a session-level advisory lock, so it is honoured across processes and released when the session ends. -
No cross-server atomicity. An account can bind files on several servers, but a unit of work spanning two of them cannot be one transaction.
Authentication is the server's too: a postgres role and password, a mongo
user. The password belongs in the credential store or the environment, never
in BINDINGS — see Credentials and secrets below.
Rather than write the daemon host into every file's binding, define a
named connection once and reference it. The committed BINDINGS
names only the connection; the deployment-specific host, namespace, and
token live in the local (git-ignored) .mvx-private/connections:
> SET-CONNECTION salesdb driver=postgres address=mvxdb:5432 dbname=mvx user=app password=<pw> namespace=SALES
> CREATE-FILE ORDERS USING @salesdb
> CREATE-FILE CUSTOMERS USING @salesdb
BINDINGS: ORDERS @salesdb
CUSTOMERS @salesdb
Move the database to a new host and you change one field —
SET-CONNECTION salesdb address=newhost:5432 — with no change to
BINDINGS and nothing to re-commit. The same profile carries the password,
so a connection is the single place a file's whole remote binding lives;
LIST-CONNECTIONS shows the profiles with secret fields masked. It is also
how a container injects a target — set MVXCONN_SALESDB_ADDRESS and
MVXCONN_SALESDB_PASSWORD and no file is needed.
The local backends read a profile too (mvx#326), which is how a sqlite or
lmdb database lives outside the account — on a mounted volume, where a
read-only image needs it. The inline form (CREATE-FILE … USING postgres <addr>) still works for a one-off.
A file can live on a PostgreSQL database instead of LMDB. Each
account/namespace is a schema; each file is a table
(id text, doc jsonb) — the record is a JSON document, one field per
attribute, and the id is text you can read. Records round-trip
byte-exact, value marks and all. Define a postgres connection and bind
through it:
> SET-CONNECTION pgmain driver=postgres address=db:5432 dbname=mvx user=app password=<pw> namespace=sales
> CREATE-FILE ORDERS USING @pgmain
> LIST ORDERS NAME PRICE
LIST, SELECT, SORT, and COUNT work as on any file. The driver is
built only where libpq is present (it is skipped otherwise), and it
authenticates with the credentials in the connection profile
(.mvx-private), never in committed config.
The Postgres backend goes well beyond the minimal contract: mapped
files push WITH/BY/FIRST queries down to SQL (see the mapping
chapter), CREATE-INDEX builds native indexes, LISTF on an account
bound entirely to Postgres (* @conn) enumerates the schema's record
tables (DICT.<file> and association child tables excluded), and
record locks are arbitrated across processes — a READU takes a Postgres session-level
advisory lock keyed by the file and record id, so a READU from one
process blocks (or, with LOCKED, reports busy to) another process on
the same PG-backed file, and the lock releases on WRITE / RELEASE or
when the holding session ends. No extra tables or configuration: the
advisory locks live in Postgres itself. The advisory lock is taken for
native-mapped and unmapped files (where the backend holds the
authoritative data); a mirror-mapped file, whose SQL is only a derived
projection of an authoritative record blob, uses the process-local lock
table instead.
A file can live on MySQL or MariaDB through the mysql driver — one table
per file, (id, doc), the record as a JSON document. Both servers are
supported and the driver avoids MySQL-only syntax where it can: attribute
expressions use JSON_UNQUOTE(JSON_EXTRACT(...)) rather than the ->>
shorthand, which MariaDB does not accept.
> SET-CONNECTION mydb driver=mysql address=db:3306 dbname=mvx user=app password=<pw>
> CREATE-FILE ORDERS USING @mydb
One difference between the two, and it is about indexes. Indexing an un-mapped attribute needs a multi-valued index — MySQL 8.0.17 and later — because the push-down asks whether any one of the attribute's values matches, and a multi-valued index is the only kind that question can use. MariaDB does not implement multi-valued indexes at all, so there:
> CREATE-INDEX ORDERS CITY
ORDERS.CITY cannot be indexed by this backend
the query still works -- the filter runs in the backend or the
verb, unindexed, so only speed is lost
Nothing silently changes meaning: the WITH is still pushed into MariaDB as
JSON_CONTAINS(JSON_EXTRACT(...)) and still answers correctly, including for
multivalued attributes. Only the index is absent, so the server scans.
The workaround is to map the field first, which gives it a real column that both servers index normally:
> CREATE-MAP ORDERS CITY
> CREATE-INDEX ORDERS CITY
index ORDERS.CITY built, 2 record(s)
| MySQL 8 | MariaDB | |
|---|---|---|
| mapped column | indexed | indexed |
| un-mapped attribute | indexed (multi-valued) | refused; pushed down and scanned |
The driver is built only where a MySQL/MariaDB client library is present and is skipped otherwise.
A file can also live on MongoDB. Each account/namespace is a
database; each file is a collection; and each record is a document
{ _id: "<id>", doc: { … } } — a string id you can read, and the record
as a BSON sub-document. Records round-trip byte-exact, value marks and
all. Define a mongo connection and bind through it:
> SET-CONNECTION mongomain driver=mongo address=db:27017 namespace=sales user=app password=<pw>
> CREATE-FILE ORDERS USING @mongomain
> LIST ORDERS NAME PRICE
address is host:port (or set a full mongodb://… URI as the
connection instead of @name); credentials come from the connection
profile (.mvx-private), never committed config. The driver is built
only where libmongoc (the mongo-c-driver, v1 or v2) is present, and
is skipped otherwise.
CREATE-MAP (see Relational mapping) works
against Mongo in mirror mode: the record blob stays authoritative, and
each mapped dictionary column is projected onto the document as a native
BSON field — scalar columns as top-level fields, and an association as an
embedded array of sub-documents (the natural Mongo shape). Given a
MORD file mapped on NAME, STATE, PRICE, and an association LINES
(PRODUCT, QTY), a record stores as:
{ _id: "O1", rec: <blob>,
NAME: "Widget", STATE: "NSW", PRICE: 9.99,
LINES: [ { PRODUCT: "A", QTY: 2 }, { PRODUCT: "B", QTY: 3 } ] }
NUMERIC columns store as BSON numbers (so they sort, sum, and display as numbers). With the projection in place:
- an equality
WITHfilter (=/#) on a mapped column and a filteredCOUNTrun server-side ($in/$nin, so the record need not stream to the verb); -
CREATE-INDEXon a mapped column builds a real Mongo index (createIndexes), and indexed lookups read from it.
The rec blob is updated in place ($set) rather than replaced, so mapped
fields survive a write that did not change them. Still evaluated in the verb
by scanning (client-side): native read-back (mirror mode only), SUM /
BY / multi-condition push-down, raw-attribute filters (Mongo cannot split
the blob server-side), TRANS() joins, DESCRIBE, and cross-process
locks — see #62.
Backends that authenticate — a networked-LMDB namespace token, a Postgres
username and password — need a secret to connect. Those secrets must
not live in git-committed account config (BINDINGS, .mvx, the
VOC): a clone or BUILD should provision an account without carrying its
credentials, with the operator supplying them out-of-band.
So secrets live in a per-account directory .mvx-private/, a single
credentials file, netrc/pgpass-style — created 0700/0600, and
git-ignored. BINDINGS names only the non-secret reference (driver,
target, key); the runtime resolves the secret from the store.
> SET-CREDENTIAL mongo mvxdb-a:27017 SALES token=abc123
> SET-CREDENTIAL postgres db:5432 mvx user=app password=s3cret
> LIST-CREDENTIALS values are masked
.mvx-private/credentials then holds one field per line — the first
three whitespace tokens are driver target key, and the rest of the line
is field=value, where the value runs to end-of-line and may contain
spaces and other characters (so an arbitrary secret survives verbatim):
mongo mvxdb-a:27017 SALES token=abc123
postgres db:5432 mvx user=app
postgres db:5432 mvx password=p@ss w0rd :/@
An environment variable overrides the file so a container can inject a
secret without writing one — MVXCRED_<DRIVER>_<KEY>_<FIELD>,
upper-cased with non-alphanumerics as _ (e.g. MVXCRED_LMDBNET_SALES_TOKEN).
Resolution is env override, then the file, then a clear error.
The store is plain text with an end-of-line value, so a deploy job can pull secrets from GitHub Actions secrets and write them out. Two patterns:
Whole file as one secret — keep the entire credentials file content
in a single repository/environment secret and write it with a tight umask:
- name: Write credentials
run: |
mkdir -p account/.mvx-private
( umask 077; printf '%s' "${{ secrets.MVX_CREDENTIALS }}" \
> account/.mvx-private/credentials )Per-field env vars — map individual secrets to the override variables and write no file at all:
env:
MVXCRED_POSTGRES_MVX_PASSWORD: ${{ secrets.PG_PASSWORD }}
MVXCRED_LMDBNET_SALES_TOKEN: ${{ secrets.SALES_TOKEN }}Either way the secret never lands in git; .mvx-private/ stays local to
the deployed account.
Keep .mvx-private/ out of version control — it is a dotfile, so MVX's
own account export already skips it; for an account kept in a plain git
repo, add it to .gitignore alongside mvxdata.lmdb/. The consuming
backends (networked-LMDB tokens, the Postgres driver) are tracked in
#7,
#9, and
#11.
Hash files are binary, so an account is never committed as-is. Git
tracks the directory form — each file as NAME/ (one text file per
record) beside NAME.DICT/ (its dictionary) — while a live account is
hash files with no .DICT.
mvx-git-adopt turns the first into the second:
mvx-git-adopt <account> directory form -> live hash files
It goes one way only. Every file is rebuilt as the backend its
dictionary's %FILE% control record names — a hash file for an lmdb
file, a directory file (keeping its NAME.DICT) for a dir file — then
BUILD catalogs BP and links packages. %FILE% is the file definition,
so a committed dictionary alone recreates its file.
There is no export tool and no --export flag. The other direction is not
a conversion step at all: mvx-git writes the directory form itself, from
the live records, as part of add/commit (see Version Control).
You rarely call mvx-git-adopt directly — mvx-git clone adopts for
you when the clone carries a .mvx descriptor. Run it by hand only for a
plain git checkout — the bootstrap that turns mvx-lang's own system
account and packages into real accounts before mvx-git exists.
This is the stock-to-site delivery path — combined with record-level branch, merge, and cherry-pick (see Version Control), a customised site tracks stock upstream and feeds features back.
An id is a byte string. Usually it is a key somebody typed; occasionally it has a mark or a raw byte in it. It is stored as text, so a record reads as itself in the tools a DBA already uses:
sales=> SELECT id FROM "ORDERS" ORDER BY id;
id
--------------
50%25 OFF
A%FDB
Café
INV-2026-001
Only the bytes the database's own character set cannot carry are
escaped. On a UTF-8 database Café is stored as itself and only a
mark byte becomes %FD; on a LATIN1 or SQL_ASCII one almost nothing
is escaped at all. Two bytes are escaped everywhere — % (written
%25, which is what makes decoding unambiguous without a marker) and
NUL — and one in one position: a trailing space, written %20,
because a PAD SPACE collation would otherwise read A and A as the
same key.
The character set is a property of the stored data, never of your
session: Postgres asks server_encoding and MySQL the database charset,
and each pins its session to match so nothing is transcoded on the way
in or out. It is recorded beside the format —
sales=> SELECT obj_description('"ORDERS"'::regclass);
mvx: format=3 idcs=utf8 idenc=UTF8
— and a file whose stamp disagrees with the database it is now in is
refused by name rather than half-read. That matters after a dump is
restored into a differently encoded database: the text is re-spelled by
the restore, so every key built afterwards would silently miss.
(SQLite records the format in PRAGMA user_version; Mongo needs
neither, because a BSON string is UTF-8 by definition.)
Decoding never depends on any of this — it is one operation, forever — so an id written under any character set reads back byte-exact.
mvx-doc-migrate brings every file in a location up to the current
format, in place, and is safe to re-run:
$ mvx-doc-migrate sqlite /srv/acct/acct.sqlite
$ mvx-doc-migrate mysql 'host=db port=3306 user=app password=… dbname=sales'
$ (cd /srv/acct && mvx-doc-migrate postgres @pgmain)
$ (cd /srv/acct && mvx-doc-migrate mongo @mongomain)
It does two conversions: a pre-#157 record blob becomes a JSON document,
and a binary record id becomes text. It also re-spells ids when the
character set has changed under them, recovering the original bytes
through the database's own inverse conversion (convert_to in Postgres,
CONVERT … USING in MySQL) rather than guessing. Each table is judged
by its own stamp, so a file already spelled for this database is left
alone. A conversion that cannot be completed writes nothing and says
which file and why.
embedded LMDB -> networked LMDB -> (heavier backends)
Each step is configuration, not surgery, because the driver contract is the boundary. Migration can be per file: a directory file, an embedded file and a daemon file coexist in one account.
$MVXPRIV comes from the process environment — system-level
configuration that account data cannot write. In containers, run app
sessions stateless with the account's data on a volume or on a shared
server, and grant unrestricted only where a shell escape is truly
needed.
MVX can keep a roster of who is logged on. It is optional: with no registry
running, everything behaves exactly as it did before — WHO prints the user and
the account, @USERNO is 0, and no program is affected.
mvx-msgd -s /run/mvx/msgd.sock # the registry, one per hostSessions find it through $MVXMSGD (default /tmp/mvx-msgd.sock). Each session
that starts takes the lowest free port number, and LISTU shows the roster:
> LISTU
Port User Account Logged on Line
1 fred SALES 09:14:22 /dev/ttys004
2 jane SALES 09:31:05 /dev/ttys007
2 session(s) on prefix SALES
The connection is the lease. A session holds its socket open for as long as it lives, so a clean logoff, a killed process and a dropped SSH all remove it from the roster with nothing to clean up. There are no stale entries to sweep.
A port is scoped by a prefix, which defaults to the account name, so a user keeps the same address whichever host they log on to. Set it explicitly where several accounts should share one space, or where one should be split:
mvx-msgd -s /run/mvx/msgd.sock -x SALES -b 1-b sets the lowest port number, and -x the prefix. A verb runs as a child of
its shell and joins the port the shell already holds, so a command does not log a
second session on.
> MSG !2 the backup has finished
sent to 1 port
> MSG * system going down at 18:00
sent to 4 ports
> MSG SALES please log off
> MSG @fred your report is ready
A port that is not logged on receives nothing, as classic Pick did, and MSG
says how many ports it reached. Sending to one port is ungated; * needs an
msgwall permit and a whole account an msgbroadcast one, checked in the
runtime rather than in the verb — anyone who can compile could otherwise call
MSGSEND directly.
A message never writes on a program's screen. It waits in the session's inbox until the program asks for it, so a full-screen program keeps its form:
LOOP
K = KEYIN(250)
IF MSGPENDING() > 0 THEN GOSUB SHOWMSG ;* paint it where you choose
...
REPEATexamples/MSGDEMO.b is a working example. A program that never calls
MSGPENDING never sees one, which is what you want for a report that runs for
an hour, and MSGMODE("OFF") has the daemon discard them on arrival — except a
wall or system message, which a user may not switch off.
At the TCL prompt a message appears as it arrives, even mid-command: the shell waits on the terminal with a deadline rather than blocking in a read, so an arriving message prints and the line you were typing is redrawn underneath it, untouched. There is no signal handler anywhere in this path — a deadline on a poll, not an interrupt.
The intrinsics are in Intrinsics. SEND and MESSAGE are the
statement spellings, for the same thing:
SEND "Backup starting" TO 7 THEN PRINT "sent" ELSE PRINT "port 7 is off"
MESSAGE OFFSEND takes a port, as D3 spelled it, and takes THEN when the message
reached somebody — a user, an account or a wall goes through MSGSEND. Neither
word is reserved: MESSAGE is exactly the name a program would give a variable,
so each is a statement only in a shape that cannot be anything else, and
SEND = "x" still compiles.
The inbox holds 256 messages (64 KB) and drops the oldest, counting what it
lost, so MSGDROPPED() can say so honestly. Sending is rate limited per port.
A message is an ordinary MV record, so it needs no decoding:
1 version 2 class + flags 3 from port 4 from user 5 from account
6 from host 7 sent 8 text 9 payload
A message can carry an attachment: where to find a record, so the person receiving it can open it.
> MSG !7 WITH ORDERS O1234 have a look at this
sent to 1 port, with ORDERS O1234
MSG refuses to send a pointer to a record that is not there — otherwise the
receiver finds out by trying to open it, long after you have gone.
On the receiving terminal:
[3] jane: have a look at this
attached: ORDERS O1234 — on the stack as "CT ORDERS O1234" (.1 to recall)
The terminal offers; it does not open. The command goes on the stack and the record opens when you recall it and press return. A sender can hand you a screen, and cannot take yours.
Attribute 9 carries, by value mark:
1 file 2 record id 3 program 4 mode ("view" or "edit")
Positions 5 and beyond are free for an application, and a reader ignores what
it does not recognise — which is what lets the convention grow without every
reader being taught first. MODE picks the verb the shell offers (CT to
look, ED to change); a named PROGRAM wins, because an application knows its
own screens better than the shell does. A program builds one with MSGSEND:
PAY = "ORDERS":@VM:ID:@VM:"ORDER.ENTRY":@VM:"edit"
X = MSGSEND("@":WHO, "can you fix this one?", 0, PAY)Locks are a separate problem. A screen written with edit and read-only modes should offer the view mode when the record is locked elsewhere; that is application behaviour, not something the runtime decides.
A single host needs no service at all: mvx-msgd with its built-in loop
transport is the whole system. To reach ports on other machines, point each
host's daemon at a message broker:
mvx-msgd -s /run/mvx/msgd.sock -t mqtt -c tcp://broker:1883 -x SALES -b 1-
-tnames the transport.loop(the default) is one host and no service;mqttis built when libmosquitto is present (MVX_WITH_MQTT). -
-cis the broker. Credentials come from the daemon's environment —MVXMSG_USERandMVXMSG_PASSWORD— so they never reach a client process. -
-xis the port prefix, shared by every host that should see one another. -
-bis the lowest port number this host hands out. Give each host its own block (1, 201, 401), so two daemons never allocate the same number.
docker/compose.msg.yaml runs a broker and two application containers, each
with its own daemon, as a working example.
The roster spans hosts. Each daemon announces its sessions on a retained
topic and clears them when they go, so WHO and LISTU list every machine
sharing the prefix, and MSG !201, MSG @fred and MSG SALES all resolve
against the whole system and report a real count.
A daemon that dies cannot clear anything, so it registers a last will: an empty message on its own topic, which every other daemon reads as "forget what came from there". Its sessions leave the other rosters within seconds, without anyone waiting for a timeout.
Where a service offers neither retained messages nor a will, the daemon falls back: sessions are re-announced periodically and entries that go quiet expire. The roster is then a little slower to settle, and just as correct.
A broker that is down does not take messaging away. Sessions on the same
host go on messaging each other, publishes are buffered and flushed on
reconnect, and MSGSTATUS() reads degraded rather than up:
> MSG !2 still works
sent to 1 port
The transport is a driver, like a storage backend: libmvxmsg_mqtt is the only
thing that links libmosquitto, and another service can be added without
changing anything above the daemon.
Every program is present, whether or not it ever sends a message. A report
that runs for an hour shows up in WHO, and a message sent to its port has
somewhere to go. Registering only programs that use messaging would make the
roster a list of the programs that use messaging.
One person at one terminal is one port. A verb, a program run with
EXECUTE, and a cataloged subroutine all join the session they were started
from rather than taking a port of their own:
> PORTSHOW
port=1
execute=1
> LISTU
1 fred SALES app1 09:14:22
1 session(s) on prefix SALES
A program that crashes returns you to TCL, still logged on. The shell holds the session; a program only attaches to it. The port, the roster entry and any queued messages survive. Only when the shell itself goes does the session end — and then it ends by itself, because the connection is the lease.