Fabric pipeline on 2026.9 bundles: Power BI 3D visual won't load a federated model

Context. Our pipeline doesn’t use the normal connector path. We don’t use Speckle.GetByUrl or Speckle.GetTables for data. It runs in Microsoft Fabric (Spark notebooks):

  1. GraphQL project.model.versions to list versions
  2. GET /api/v2/projects/{p}/models/{m}/versions/{v}/artifacts for the presigned URLs
  3. Download the parquet parts (eav.eav, eav.objects, eav.types, eav.type_eav, eav.object_type, eav.paths, …) and load them into a lakehouse
  4. Build our own star schema from those tables and serve Power BI from it

That part already works with 2026.9. We ingest our largest model’s first 2026.9 version (<v-new>) correctly: 27,393 elements. Our data layer isn’t what’s broken.

What’s broken is the 3D visual. Because our data doesn’t come from GetTables, we build the modelInfo payload ourselves. It’s base64 JSON with schemaVersion: 2, pipeline: "artifact", server, projectId, modelId, versionId and token. That works for our pre-2026.9 models. For the 2026.9 versions, the visual (2026.9.0) reports no packfile (version.objectKey and packfileSize are both null over GraphQL) and shows “This version predates Speckle 4.0”.

Specific questions:

  1. Is reading the artifact bundle parquet directly a supported contract, or an internal format you may change? If it’s internal, is there a supported path for a consumer that isn’t Power Query? We’re in Spark, so GetTables isn’t available to us.
  2. Is there a schema reference for the bundle parts? We reverse-engineered the EAV layout, and 2026.9 added eav.model.parquet and eav.path_stats.parquet, which we don’t use.
  3. For a 2026.9 version, is version.objectKey expected to be populated? If not, what should the 3D visual resolve geometry from, and what should our hand-built modelInfo contain?
  4. For the visual’s “Object Keys” role, should that be Objects[Object Key] from GetTables? Is it the bundle’s integer object index (our eav.objects row key), or a different identifier? We currently bind the Revit applicationId, which I now think is wrong.
  5. Our modelInfo payload embeds a Speckle token in a data column because the visual requires it. Is there a better supported pattern?

The 3D view for this model has been blank since 18 Sep.

Two follow-ups from digging further:

1. referencedObject doesn’t resolve for 2026.9 versions. For <v-new> it returns bundle.<project>.<model>.<v-new>. project(id).object(id:) returns null for it, and GET /objects/<project>/bundle.…/single returns 404. On the same model’s pre-2026.9 version <v-old>, both work and return 61,457 children. That matches the object-model docs page (“there is no root object” in 2026.9). Is there a replacement handle, or is referencedObject simply not usable that way now?

2. We don’t control the viewer loader. Our 3D view is your Power BI visual (specklePowerBiVisual 2026.9.0), not a web app embedding the viewer SDK. All we can do is populate its data roles. Is the supported answer “feed the visual from Speckle.GetTables and let it build modelInfo itself”?

The bigger question, and the one that matters most to us. We load about 186k elements with properties into a Fabric lakehouse, and the 3D view is a small part of the report. If the artifact bundle is internal, what is the supported way to get element and property data in bulk into a data warehouse? Speckle.GetTables is a Power Query function, so it isn’t available in a Spark notebook. If there’s a supported bulk path (specklepy, a REST endpoint, anything), that’s what we need to migrate onto.

Wassam, I’ll take this in order. The short version: your pipeline is supported, nothing is broken on your project, and the blank 3D view comes from one field in the payload you build.

The nulls you found are correct, not a fault. Checking <v-new> on our side:

schemaVersion: 3, format: bundle, hasDataset: true
referencedObject: bundle.<project>.<model>.<v-new>
hasPackfile: false, packfileSizeBytes: null

objectKey and packfileSize are packfile-era fields, and a version that was born as a bundle never has them. The same goes for the 404 on /objects/... and the null from project.object(id:). The bundle.<project>.<model>.<version> string is a deliberately non-hex sentinel, designed to always 404 on the object endpoints so it can’t collide with a content hash. You read all of that correctly. It just isn’t what’s failing.

What’s actually blanking your 3D view. The visual never queries objectKey. The “This version predates Speckle 4.0” banner is driven by exactly one thing: the pipeline field in the modelInfo blob, which you build. The banner appears when any model in the blob has pipeline: "legacy".

That explains your 18 Sep cliff. Compare the two versions on that model:

Version Date objectKey schemaVersion
<v-old> 16 Sep present (1.35 GB packfile) 3
<v-new> 22 Sep null 3

<v-old> is a migrated version: it became a bundle but kept its packfile fields. <v-new> was born a bundle. Both load equally well. So if your Fabric code derives pipeline from something like objectKey != null (the natural reading of “does this have a packfile”), it silently flips to "legacy" at exactly the point the model started being published from a 2026.9 connector.

The fix. Our own Power Query connector hardcodes pipeline = "artifact", and it decides availability purely by whether the artifacts endpoint returns files. It never emits "legacy" at all. That value only comes from the old 3.x Speckle.GetByUrl path. So:

pipeline = "artifact" if GET /api/v2/projects/{p}/models/{m}/versions/{v}/artifacts returns a non-empty files array. Equivalently, gate on schemaVersion == 3. Never derive it from objectKey or packfileSize.

You already call that endpoint successfully for this version, and it’s the same endpoint the visual itself uses to load geometry. So a one-line change should bring your 3D view back.

The bundle format isn’t internal. It’s public, specified and versioned: github.com/specklesystems/speckle-bundle-spec. The spec is executable DuckDB SQL, with a generated human reference (docs/reference.md), a bundle validator, a semver’d schema_version with a documented bump policy, and generated constants for Python, TypeScript, C# and C++. Read it rather than reverse-engineering it. If your reverse-engineered layout disagrees with it anywhere, the spec wins.

Your two unknowns:

  • eav.model.parquet is in the spec as optional. It holds model- or document-scoped attributes: the EAV rows that have no object (Revit/Civil 3D document settings, project info, reference point).
  • path_stats is {base}.envelope.path_stats.parquet. It’s an optional sidecar the server builds, holding exact property-path statistics so consumers can skip a scan. It’s fine to ignore it.

Bulk data into a warehouse. A 2026.9 version has no object graph to traverse, so any advice about specklepy graph traversal is a dead end. What you want is specklepy.bundle, which is first-party and does exactly what you hand-rolled:

from specklepy.bundle.download import download_bundle

paths = download_bundle(account, project_id, model_id, version_id,
                        dest_dir, include_geometry=False)

It makes the same /api/v2/.../artifacts call and uses the same presigned URLs and the same parquet parts. On top of that, specklepy.bundle.spec gives you the vendored schema constants, so your column references track the spec instead of drifting. It installs as specklepy[bundle] (it needs pyarrow) and works fine in a Fabric Spark notebook. Your architecture was right. You just reimplemented a supported client.

Object Keys (your Q4). The intended binding is the dense Int64 key:

Object Key = ordinal * 2^32 + object_index

ordinal is the model’s position in your federated modelInfo list. object_index is the bundle’s integer object index, which is your eav.objects row key. Binding the Revit applicationId isn’t wrong either. It’s a supported compatibility binding, resolved through the per-version eav.objects.parquet dictionary. It just costs you a download of several tens of MB and a few hundred MB of heap on a model your size, and the Object Key binding skips all of that. It’s worth switching for 186k elements.

One trap: the identity mode is decided from Power BI column metadata first, and a numeric-looking text column is classified as an Application ID. Make sure the column lands as a genuine numeric type, or name it exactly Object Key.

The token (your Q5). Embedding a token in the blob is the architecture, not a workaround. Our connector does the same. What matters is which token. Don’t put a full PAT in there. Exchange it for a scoped embed token:

mutation CreateEmbedShareToken($input: CreateEmbedShareTokenInput!) {
  sharingMutations { createEmbedShareToken(input: $input) { token } }
}

Call it with { projectId, resourceIdString }. That’s exactly what the connector mints, and it fails hard rather than falling back to the more powerful token.

That was the detail we needed. Confirmed on our side: the banner is driven by pipeline, we now hardcode "artifact", and hasPackfile is never read.

Here’s what the visual’s own diagnostics (Dev mode) show now.

What’s working. We switched the Object Keys binding to the dense Int64 key (ordinal * 2^32 + object_index, typed Int64 and named Object Key). The visual reports:

id mode: Object Key - arithmetic resolution, no dictionary needed
data paging settled - 40268 rows
resetFilter: resolved 40268/40268 ids across 7 model(s) in 3ms

That’s 100% resolution across all seven federated models, with the dictionary skipped.

What isn’t working. Six models paint, and the seventh never does:

19:17:17 model <model-1> painting - geometry keeps streaming
19:17:20 model <model-2> painting - geometry keeps streaming
19:17:24 model <model-3> painting - geometry keeps streaming
19:17:30 model <model-4> painting - geometry keeps streaming
19:17:36 model <model-5> painting - geometry keeps streaming
19:17:39 model <model-6> painting - geometry keeps streaming

The missing one is our largest model, version <v-fed>, which has 15,012 of the 40,268 objects in scope.

The stream header reads stream 8 MB (session 8) · 0.1 MB/s (avg 0.9) · in-flight 12 · 68 prim/s, and we see data paging settled - 40268 rows repeat several minutes apart, each time followed by a resetFilter.

  1. Why would one model never paint while six do? Is there a size or object-count ceiling per model in the viewer?
  2. “session 8” suggests the stream restarts. Does a dataview update restart geometry streaming? If so, a large model may never get enough uninterrupted time to finish.
  3. Does the token type affect the streaming path? We embed a PAT. Is the scoped embed token on a different or faster path, or does it only matter for security?
  4. Is 0.1 MB/s with 12 requests in flight consistent with anything server-side, or should we be looking at our own network?

Thanks for the Dev-mode dump. It answers this faster than anything either of us could infer. I’ll take your four questions in order, then the thing that actually matters.

Is there a per-model size or object-count ceiling?
No. The six-vs-seven split isn’t a capacity signal at all. It’s an artefact of how the visual loads.

The load loop is strictly sequential: one model at a time, each awaited to completion before the next starts. The model … painting line you’re reading is pushed after that await resolves, not when the request goes out. Your six paints land 3–6 seconds apart and then stop, which means the loop did reach the seventh model and is still sitting on it. It hasn’t been skipped and it hasn’t failed. It’s blocked. Because it’s last in the list it isn’t blocking anything else, which is why the rest of the scene looks healthy.

It’s silent rather than erroring because of a gap on our side. The streaming client’s connect step has no timeout: it settles when the server sends its metadata frame, or when the socket errors or closes, and nothing else ends it. On the server side, when a pod has to fetch a cold .viewer.dat out of object storage, the progress frames it sends deliberately suppress the idle timeout, so that a healthy long copy doesn’t get killed mid-flight. Each behaviour is correct on its own. Together, they mean a stalled acquisition waits forever and never surfaces an error to you.

“session 8” isn’t a restart counter. It’s megabytes.
The header renders stream <MB since this load> MB (session <MB since the page loaded>). Your whole page session has pulled 8 MB. Nothing restarted, and a dataview update doesn’t restart geometry streaming. That also rules out the rest of that hypothesis (“a large model may never get enough uninterrupted time”).

The token type doesn’t affect the streaming path.
The socket endpoint runs the same auth stack as the artifacts REST call you already make: token, then the share-password gate, then the project read policy, then the token’s resource-access rules. A scoped embed token doesn’t get a separate or faster path. The advice on createEmbedShareToken stands, but it’s a security point only, and swapping it in won’t change this behaviour.

One thing to rule out while you’re there: if that PAT carries resource-access rules, they’re enforced per project and model and version, so a rule that covers six of your seven models would deny the seventh. That produces a visible error rather than silence, so it doesn’t fit your symptom, but it’s a thirty-second check.

0.1 MB/s with 12 in flight isn’t a server capacity signal.
The client’s own backpressure cap is 4,000 outstanding reads, and you’re at 12. Nothing on our side or yours is throttling you. The number is low because the six resident models have very little left to ask for. 68 primitives/sec against 12 outstanding works out to roughly 175 ms per read, which is round-trip latency for small-range reads, not bandwidth. Don’t go hunting through your network because of it.

What I need from you to close this.
Two things, both from the session where it hangs:

  1. The [remote-bundle] lines in the browser console. There are three, in this order:

    • a connected to … — .dat is N MB line
    • an index source: idx-artifact or index source: socket line
    • a loading scene: N prims, N placements, N objects line

    Which of them appear tells us exactly which stage the model is stuck in. They’re the only way to tell cold-artefact acquisition apart from the alternative below.

  2. The files array from your /api/v2/projects/<project>/models/<model>/versions/<v-fed>/artifacts call, specifically whether a <v-fed>.viewer.idx is in it alongside the .viewer.dat.

The second one matters because the .idx artefact is an accelerator. If you hand its URL to the loader, the index is fetched in parallel with the socket handshake. Without it, the index is read over the socket one ranged read at a time. That serialises badly on a model your size and looks exactly like this: no paint, no error, a trickle of traffic. If the .idx is missing from this model’s payload but present for the other six, that’s your answer, and it’s ours to fix.

To be clear about where this sits: your architecture is sound, you’re using first-party clients, and everything you’ve reported so far has been a real defect on our side or a documentation gap. Being some way off the usual support path just means it won’t be at the top of the pile to fix. But keep going.

I checked the second one straight away. .viewer.idx is present for all seven models, including the one that hangs. <v-fed> returns 18 files, with both <v-fed>.viewer.dat and <v-fed>.viewer.idx, and the other six are the same (15–18 files each). So the missing-accelerator theory is out.

Note that the artifacts endpoint returns only name, url and expiresAt. There’s no size field, so I can’t give you the .dat size from the API side.

Your first explanation fits our symptom exactly. The connect step has no timeout and the progress frames suppress the idle timeout, so a stalled cold-artefact acquisition waits forever and surfaces nothing. That’s precisely what we see: the loop reaches the last model, sits there, and the scene stays healthy because that model is last.

I’ll get you the [remote-bundle] console lines next. I’m also ruling out resource-access rules on the PAT, though as you say that would error visibly rather than hang.

Thanks for the depth here. The sequential-load and “session = megabytes” corrections both killed hypotheses we’d have kept chasing.

Moving this here from a support chat, with Wassam’s OK and the IDs swapped for placeholders. It’s well off the usual Power BI connector path, and it’s the kind of thing that ends up in the docs, so it’s better worked through in the open.

Where it stands: the data side is solved (specklepy.bundle plus the bundle spec), the 3D visual now resolves 100% of object keys, and one federated model still hangs silently at the streaming connect step. The next evidence we need is the three [remote-bundle] console lines from a session where it hangs.

If you’re running Speckle data through Microsoft Fabric instead of straight Power BI, especially with federated models, your experience here would be useful. I know there are a few of you.