Full DAX oracle: msmdsrv on three hosts
Status: attach, pump, and model publish landed. The bounded Go evaluator
stays the default on every OS. A real Analysis Services process
(msmdsrv.exe) is an optional oracle, reached through FABRIC_DAX_URL,
never a compose default, and never started by make up / make up-lite.
This is the host map for that oracle — the thing
33-pbix-tooling.md proved on windows-latest (Desktop
answers bit-identically) and the thing Wine / OrbStack / Rancher Desktop on
macOS cannot host.
Same move as Eventhouse (25-rti-kusto.md) and Eventstream
(51-eventstream-kafka.md): terminate Fabric’s
contract ourselves, relay the compute to a real engine. The difference is
the engine. Microsoft ported the SQL database engine to Linux; they did
not port Analysis Services. There is no mcr.microsoft.com/.../msmdsrv.
So the sidecar is a Windows guest, and the guest is started differently
on each OS.
What is in and what is out
Section titled “What is in and what is out”| In | Out |
|---|---|
Query relay: executeQueries (and the portal runner) POST DAX to a pump in front of msmdsrv when FABRIC_DAX_URL is set | Putting dockur/windows on docker compose up or --profile dax for Mac users |
| Three recipes for a machine you own that actually has a Windows kernel | Wine, Moonshine, Game Porting Toolkit, OrbStack, Rancher Desktop on macOS |
| Growing the Go subset against Desktop goldens so every-push CI stays on the in-process engine | Claiming “full DAX” because a VM can boot, or because the host recipes exist |
| Honest 502 when the URL is set and the pump is down | Silent fallback to the Go subset (that would hide a dead oracle) |
The CI oracle already on windows-latest (e2e/pbix-desktop) | UTM on macos-latest, or dockur/windows on ubuntu-latest, as a required CI job |
Wine maps user-mode Win32 onto Darwin. msmdsrv is a Windows service
(SCM, COM, named pipes, SSPI, VertiPaq). SQL Server on a Mac is the official
Linux image, not wine sqlservr.exe. Analysis Services is still listed
unsupported on Linux through SQL Server 2025. That is why this page is a
host map and not a Wine bottle.
The three hosts
Section titled “The three hosts”These rows are developer (or self-hosted) machines, not GitHub-hosted
runner labels. macos-latest is not “the macOS row.” ubuntu-latest is
not “the Linux row.” See Not GitHub-hosted runners.
| Host (a machine you own) | How Windows runs | How you start msmdsrv | What the Mac/Linux emulator sets |
|---|---|---|---|
| macOS | UTM (or Parallels) on the host, not inside OrbStack | Install Windows in the guest, then Power BI Desktop or SSAS Developer. Desktop hosts msmdsrv as a child; the port is in %LOCALAPPDATA%\Microsoft\Power BI Desktop*\*\Data\msmdsrv.port.txt — the same file e2e/pbix-desktop/desktop.ps1 already polls. Then pwsh e2e/msmdsrv/start.ps1 | FABRIC_DAX_URL=http://<guest-ip>:8080 from the Mac |
| Linux | Docker Engine on the metal (or a VM that already has KVM) + dockur/windows with /dev/kvm | Same guest install as Windows. Compose file: e2e/msmdsrv/docker-compose.yml. make dax-linux refuses unless uname is Linux and /dev/kvm exists. Pump still starts inside the guest | FABRIC_DAX_URL=http://127.0.0.1:8080 (published port) |
| Windows | Nothing. The kernel is already there | Desktop (the path e2e/pbix-desktop already runs in CI) plus FABRIC_DAX_URL (#232). Headless msmdsrv listens with a shoestring parent PID (33 Phase 0c); ROW needs a table, so it is not a ready oracle. Then pwsh e2e/msmdsrv/start.ps1 | FABRIC_DAX_URL=http://127.0.0.1:8080 |
dockur/windows is QEMU in a Linux container. It needs /dev/kvm passed
in. Their own requirements are Linux+KVM or Docker Desktop on Windows 11
with nested virt. They call out Docker Desktop on macOS as unsupported.
OrbStack and Rancher Desktop on a Mac have the same hole: the Linux VM does
not expose /dev/kvm. KVM=N (TCG) is software emulation of Windows inside
Linux inside macOS — the project itself says that is a major performance
loss. Do not use it.
On Apple Silicon, a UTM guest should be Windows 11 ARM. x64 Desktop
inside ARM Windows is emulation; prefer the ARM Desktop build when it is
the thing that hosts msmdsrv.
Not GitHub-hosted runners
Section titled “Not GitHub-hosted runners”The host table is how a person reaches msmdsrv without waiting for
Monday’s Windows job. It is not how every-push CI gets full DAX.
GitHub-hosted macos-latest and ubuntu-latest are already VMs.
Windows inside them is nested virtualization.
macos-latest+ UTM. GitHub does not support nested virtualization on macOS images. UTM is a GUI hypervisor. There is no Hypervisor.framework for a Windows 11 ARM guest on that runner.ubuntu-latest+dockur/windows./dev/kvmexists on x64 Linux runners for the Android emulator (small, cached images). GitHub still calls nested VMs experimental and unsupported.dockur/windowsis a full Windows 11 install (our compose asks for 8 GB RAM; their docs want ≥32 GB disk). First boot installs Windows. That is not a PR job, and caching a Windows VHD across jobs is a size and licence question. ARM Ubuntu runners do not get KVM.
The CI witness is e2e/pbix-desktop on windows-latest. That kernel
is Windows. Desktop hosts msmdsrv as a child. No nested guest.
Every-push tests on ubuntu/mac run the bounded Go subset against
Desktop goldens. They do not boot Windows. Empty FABRIC_DAX_URL is
what those jobs have, and what they test. A self-hosted runner that
really is Linux+KVM or a Mac with UTM on the metal may use the recipes
above; that is a human’s machine, not runs-on: ubuntu-latest.
The attach point
Section titled “The attach point”client --executeQueries--> fabric-emulator | FABRIC_DAX_URL empty → internal/semanticmodel | FABRIC_DAX_URL set → POST {url}/v1/deploy | POST {url}/v1/dax v pump (AMO + ADOMD.NET) | v msmdsrv.exemsmdsrv does not speak executeQueries REST. The pump is a thin HTTP
front (same job as Microsoft’s own
azure-analysis-services-http-sample):
ADOMD to localhost:<port>, JSON back. The emulator still does Power BI
audience auth, workspace RBAC, and model-id resolution. Only the EVALUATE
is forwarded.
Pump contract:
POST {FABRIC_DAX_URL}/v1/deploy{"tmsl":{"createOrReplace":{"object":{"database":"RetailAnalysis"},"database":{…}}}}
POST {FABRIC_DAX_URL}/v1/dax{"query":"EVALUATE SUMMARIZECOLUMNS(...)","catalog":"RetailAnalysis"}{"rows":[{"Customer[Country]":"US","[Revenue]":101.72}]}Deploy runs once per item definition (sha256 of the TMSL). Import rows from
the item’s data.json become DATATABLE calculated partitions — VertiPaq
will not read our JSON, and pbix-mcp
is not a runtime dependency (33). Direct Lake tables
are refused rather than dropped.
Desktop’s workspace instance often rejects CreateOrReplace of a new
database (it already has the open .pbix). The pump returns 409
DAXDeployRejected; the emulator then queries whatever catalog is loaded —
the Phase 1 hand-open path still works. SSAS Developer / headless msmdsrv
accepts the publish.
A 4xx from the pump is a DAXQueryError (400). An unreachable pump is
DAXEngineUnreachable (502). The Go subset is not consulted when the
URL is set — a dead oracle must be loud.
Empty FABRIC_DAX_URL (the default) keeps today’s evaluator. No 501 on
the query path: the subset is a real engine for the rows it pins.
GitHub-hosted ubuntu/mac jobs never start msmdsrv; they test this
default. See Not GitHub-hosted runners.
Running the pump
Section titled “Running the pump”The pump is e2e/msmdsrv/pump — a net8.0 Kestrel
front that opens a new ADOMD connection per request. It must run inside
the Windows guest. ADOMD’s bare localhost:<port> form is Windows-only on
.NET Core, and that is the form a loopback msmdsrv answers. Same
Microsoft.AnalysisServices.AdomdClient.NetCore.retail.amd64 19.*
package as e2e/pbix-desktop/probe (x64-emulated on ARM Windows).
On the guest, start the pump. SSAS / headless msmdsrv needs only a
listening engine (MSMDSRV_PORT or MSMDSRV_DATA_SOURCE). Desktop still
needs a process hosting msmdsrv — open any .pbix so the port file
exists; the emulator then tries CreateOrReplace and falls back to the
open catalog if Desktop refuses.
pwsh e2e/msmdsrv/start.ps1GET /health opens ADOMD and returns {"ok":true,"port":"…"}. Allow inbound
TCP 8080 on the guest firewall if the emulator is on another host.
| Env | Default | Meaning |
|---|---|---|
MSMDSRV_PUMP_ADDR | http://0.0.0.0:8080 | Kestrel listen URL |
MSMDSRV_PORT | (Desktop msmdsrv.port.txt) | Loopback port. Unset → re-read the newest Desktop port file per request (Desktop can restart and move it) |
MSMDSRV_CATALOG | (empty) | Optional Initial Catalog= |
MSMDSRV_DATA_SOURCE | (empty) | Full ADOMD connection string; skips port discovery (SSAS named instance) |
On the emulator host:
export FABRIC_DAX_URL=http://<guest-ip>:8080 # UTM / Parallelsexport FABRIC_DAX_URL=http://127.0.0.1:8080 # dockur published port, or native WindowsPhases
Section titled “Phases”Phase 1 — attach and relay (landed)
Section titled “Phase 1 — attach and relay (landed)”FABRIC_DAX_URLon the config / server /executeQueriespath.- Unit tests: empty URL uses the Go evaluator; set URL forwards and does not fall back; unreachable pump is 502.
- Host recipes on this page.
make dax-linuxis a guard, not a family profile. - Pump:
e2e/msmdsrv/pump+e2e/msmdsrv/start.ps1, run inside the Windows guest. Compose publishes 8080; it does not start the pump.
Phase 2 — publish the model (landed)
Section titled “Phase 2 — publish the model (landed)”executeQueries POSTs CreateOrReplace TMSL to /v1/deploy before the
first /v1/dax. The pump runs it through AMO Server.Execute. Desktop
that will not create a database returns 409 and the query still goes to
the open catalog. Opening a .pbix by hand remains valid; it is no longer
required on a host that accepts TMSL (SSAS Developer, later headless
msmdsrv).
DATATABLE columns must set sourceColumn (the BIM sourceColumn, or the
column name). Desktop rejects the omit. The pump also maps that refusal —
and empty-partition errors — to 409 so a still-open .pbix stays queryable.
If the named catalog is missing on Desktop’s workspace instance, /v1/dax
retries without Initial Catalog= and hits the open file.
Phase 3 — grow the Go subset against the oracle
Section titled “Phase 3 — grow the Go subset against the oracle”Unchanged from 33: every new function is one Desktop
agreed about. A developer VM (UTM / dockur on metal) is how a Mac or
Linux laptop reaches that oracle without waiting for Monday’s
windows-latest job. Every-push CI on ubuntu/mac does not boot that
VM; it replays the goldens against Go. That is what keeps those jobs
honest: they test the engine those runners actually have.
Pins so far, in
e2e/semantic-model/fixtures/desktop_goldens.json,
replayed by TestDesktopFunctionGoldens: ACOS, then ABS (BLANK stays
BLANK), then ROUND (half away from zero; BLANK number stays BLANK,
BLANK digits count as 0), then LOG / LOG10 (default LOG base is 10;
BLANK/<=0 and LOG base 1/<=0 error), then INT (floor toward −∞;
BLANK stays BLANK), then MIN (BLANK is not a candidate; all-blank is
BLANK), then AVERAGE (BLANK is not in the mean), then COUNT
(non-blank cells, including text; empty is 0), then POWER (BLANK base
is BLANK; BLANK exponent is 0; POWER(0, 0) errors), then SIGN (BLANK
stays BLANK; SIGN(0) is 0), then ASIN / ATAN (BLANK stays BLANK;
ASIN outside [-1, 1] errors), then PI / SIN / COS / TAN
(COS(BLANK()) is 1; SIN/TAN BLANK stays BLANK; TAN of a right
angle errors), then DEGREES / RADIANS (BLANK stays BLANK), then
DATE / YEAR / MONTH / DAY (two-digit years 0–30 → 2000s, 31–99 →
1900s; month/day overflow; day <=0 errors; YEAR(BLANK()) is BLANK),
then TIME / HOUR / MINUTE / SECOND (wraps modulo 24h; BLANK parts
are 0; negative total errors), then the rest of the Phase 3 batch:
SWITCH (BLANK equals only BLANK), DISTINCTCOUNT / MAX, SQRT,
MOD (MOD(-10,3)=2), FLOOR / CEILING (CEILING(n,0)=0), LN,
EXP, WEEKDAY / WEEKNUM, EOMONTH / EDATE, TRUNC (toward zero),
QUOTIENT (toward zero; BLANK divisor errors), BLANK / ISBLANK
(ISBLANK("") is false), then ROW (one-row constructor; keeps a BLANK
cell, unlike SUMMARIZECOLUMNS which drops all-blank groups; names must
be unique non-empty strings). ATAN2 is not a DAX function. Captured on a
UTM Windows 11 ARM guest + Desktop.
Clone that guest only while it is stopped (utmctl clone refuses a
running VM). Do not treat the live oracle as disposable.
Phase 4 — XMLA write-through (only if demanded)
Section titled “Phase 4 — XMLA write-through (only if demanded)”executeQueries is the tractable surface. XMLA already terminates on the
Go evaluator. Relaying XMLA Execute to msmdsrv is a separate cost and
is not a Phase 1 blocker.
What this does not change
Section titled “What this does not change”- Parity row Power BI — DAX beyond the bounded subset stays 🟡 until
the pump step on
e2e/pbix-desktop(windows-latest) has been seen green. That job already opened Desktop; it now also startse2e/msmdsrvand POSTs/v1/dax. Weekly,continue-on-error, Windows-only. It is not replaced by UTM onmacos-latestordockur/windowsonubuntu-latest. make up/ OrbStack / Rancher Desktop on a Mac are unchanged.- No Microsoft OSS engine was found to vendor
(tmdl-parser is an empty
stub; SemPy / TOM / ADOMD are clients). A
tabular-emulatorremains a clean-room write if we want full DAX without a Windows guest.
Licence
Section titled “Licence”Same caveat as 33 Phase 0b: Microsoft documents
-quiet ACCEPT_EULA=1 for Desktop install. Automated use of Desktop or
SSAS in CI is a human decision. This page is the host map, not that
reading.
Cost, measured where we have numbers
Section titled “Cost, measured where we have numbers”| Path | What we know |
|---|---|
e2e/pbix-desktop on windows-latest | ~7–8 min, 698 MB Desktop download, bit-identical on the fixture query (5/5). Same job now starts the DAX pump and POSTs /v1/dax |
| UTM on Apple Silicon | Windows guest: budget 8–16 GB RAM, minutes to boot, plus Desktop inside it |
dockur/windows on Linux+KVM | Image + Windows install disk (their docs: ≥32 GB free, 2–4 GB RAM for the guest). First boot installs Windows; later boots reuse /storage |
| Wine / GPTK / OrbStack | Not a path. Do not budget it |
GitHub macos-latest + UTM | Not a path. Nested virt unsupported on those images |
GitHub ubuntu-latest + dockur/windows | Not a PR job. Nested Windows install is the wrong size and GitHub does not support it |