Plan 005 · Dapr sibling runtime

Orleans vs Dapr

Same platform, two runtimes. StreamsForge's SQL engine, contracts, application logic, REST/SignalR API, and console SPA (~70% of the codebase) are shared verbatim between an in-process Microsoft Orleans silo and a sidecar'd Dapr deployment — only the actor/grain runtime and the transport underneath differ. This page is the honest answer to "why Orleans and not Dapr, or vice versa": measured latency, delivery-semantics tradeoffs, and the decision matrix for which building blocks fit which constraints.

Live demos

Every claim on this page can be shown running. Same console, same seeded market-data world, one per runtime — pick whichever environment is in front of you:

EnvironmentOrleans consoleDapr console
Cloud Run demo (public; wakes on first click — Orleans ~30 s, Dapr up to ~2 min, sleeps again after ~15 min idle) streamsforge‑orleans…run.app streamsforge‑dapr…run.app
Local dev servers (dotnet run / dapr/tools/run.sh) localhost:5199 localhost:5399
Local containers (started from the cluster admin or docker compose) localhost:6199 localhost:6399

Demo logins: admin/admin123! · editor/editor123! · viewer/viewer123!. Suggested walk-through: Dashboard (live tape) → Tables (Z-set rows updating) → AI Control (ask it to create a source) → this page's latency scoreboard.

Microsoft Orleans in 60 seconds

A .NET framework for building stateful, distributed services out of virtual actors ("grains") — born at Microsoft Research, battle-proven running Halo's and Xbox Live's backend services, now a first-class part of .NET.

  • The big idea — virtual actors. A grain is a tiny single-threaded object with identity, state, timers, and streams. It always exists: the runtime activates it on demand somewhere in the cluster, deactivates it when idle, and recovers it after failures. No locks, no placement logic, no lifecycle code in your app.
  • One process, no extra moving parts. The silo co-hosts with ASP.NET Core — StreamsForge's Orleans flavor is literally dotnet run: REST, SignalR, gRPC, and thousands of actors in a single deployable.
  • Typed, in-process, microsecond calls. Grain-to-grain calls are plain C# method calls with compile-time contracts — the foundation of this platform's partitioned dataflow (plan 003) and its 1 ms push-transport latency.
  • Scale = add silos. The same code shards across a cluster; the runtime rebalances grains. (This demo deliberately runs single-silo.)

Official material: Orleans documentation (Microsoft Learn) · official overview — "the pitch" for newcomers · github.com/dotnet/orleans

Dapr in 60 seconds

A CNCF-graduated runtime that gives any language distributed-systems building blocks through a sidecar: your app talks plain HTTP/gRPC to localhost, Dapr does the hard parts.

  • The big idea — building blocks over APIs. Pub/sub, state stores, actors, service invocation, bindings, secrets, workflows — each a small, uniform API served by the daprd sidecar next to your process. Your code has no infrastructure SDKs in it.
  • Polyglot by construction. Anything that can make an HTTP call is a first-class citizen: StreamsForge's Python enricher, TypeScript/bun and Java consumers plug into the same topics as the .NET host — shown live.
  • Swap infrastructure with YAML. The pub/sub and state components point at Redis here; retargeting to Kafka/Postgres/cloud services is a component file, not a code change.
  • Actors included. Dapr's actor model is the virtual-actor design Orleans pioneered — same mental model, reachable from every language, with k8s-native ops (this demo runs the whole stack as sidecars in one Cloud Run service).

Official material: dapr.io · official overview — concepts for newcomers · github.com/dapr/dapr · CNCF project page

Executive summary

Architecturally, Orleans should win on raw latency — in-process grain calls and in-memory streams put the whole pipeline inside one process with no serialization or network hop between stages — and Dapr should trade that latency for reach: any sidecar'd process in any language can publish into or subscribe out of the platform's pub/sub topics, with building blocks (actors, pub/sub, state store) that map directly onto k8s-native primitives an ops team already runs. What this wave actually measured contradicts the latency half of that story: this wave's benchmark run first hit a live Orleans-flavor SignalR relay bug (zero tableDelta/pipelineResult samples — since root-caused and fixed, see the callout in Measured latency), and even with that bug fixed and real numbers on both sides, Dapr measured faster on every signal collected. Typed single-runtime performance and polyglot reach remain the two poles worth choosing between — but read the latency row below as "what we found," not "what the architecture diagrams predict." Neither runtime is "better" in the abstract — the table is the actual decision function.

If your team is…Orleans flavorDapr flavor
.NET-only, one deploy unit✓ natural fit — no sidecar, no extra moving partsworks, but pays sidecar overhead for nothing
under a polyglot mandate (Java/Kotlin/Python/TS teams need to write processors)closed door — streams are a .NET-internal transport✓ the point — sf-sources ingress + sf-source-{name} egress, any language
latency-critical (single-digit-ms tails matter)architecturally favored, NOT what was measured — real tableDelta p50 122 ms, ~17× slower than Dapr, after fixing a startup-race relay bug found this wave — see Measured latency✓ measured real, low (p50 2–7 ms) on both signals
under a k8s-native ops mandate (sidecars, building blocks, no custom clustering)Orleans clustering is its own operational model✓ natural fit — actors/pub/sub/state store are k8s-shaped primitives
needing partitioned scaling of one hot table (2–16 partitions, frontier-consistent reads)✓ full support — see plan 003descoped by design — rejected with a clear 409; sidecar hops invert the economics at this scale (decision D-F)
needing typed gRPC streams to external consumers today✓ full — static + dynamic reflection, typed client codegenreserved, not yet serving (:5499, phase 2) — REST/SignalR + pub/sub are the Dapr-flavor integration points meanwhile

Architecture side-by-side

Both flavors compile the identical streaming SQL via the identical StreamsForge.Engine, seed the identical demo world (SeedCatalog), and serve the identical REST/SignalR contract (shared/StreamsForge.Api) — see the reuse scorecard. What differs is everything below that surface: how an entity's state and timers are hosted, and how events move between entities.

Orleans — in-process grains + memory streams

GeneratorGrainper-event grain timer
▼ in-process stream push
PipelineGrainsubscribes stream directly
TableGrainsubscribes stream directly
▼ in-process stream push (table-delta)
StreamBridgeServicesame process → SignalR
One processsilo + web server, one machine hop total: process → browser

Dapr — sidecar'd actors + Redis pub/sub

GeneratorActorbatched timer (200ms tick)
▼ sidecar publish (HTTP/gRPC)
Redis pub/subsf-sources topic
▼ sidecar deliver → host subscription endpoint
PipelineActorvia PipelineEventRouter
TableActorvia TableEventRouter
▼ sidecar publish → Redis → sidecar deliver (table-delta)
DaprStreamBridgesubscription endpoint → SignalR
App + sidecar, two processesevery hop crosses a sidecar; polyglot processes attach at the Redis pub/sub layer

Structurally, Orleans' path is function calls inside one process; Dapr's path is app → sidecar → Redis → sidecar → app, repeated at every stage boundary (decision D-D: fixed envelope topics, since per-entity topics can't be subscribed without a restart). That structural difference is the intuitive explanation for why Dapr should be slower — see Measured latency for what this wave actually found, which does not confirm that intuition.

Measured latency

Methodology. Same seeded table (order_states — SELECT order_id, symbol, side, stage, stage_rank, stage_ts, qty, filled_qty, px FROM order_events LATEST BY (order_id)), same machine, same clock (no skew to correct for), one runtime at a time (never concurrently, so both get the full machine's CPU). A bun script (tools/bench/latency.ts) subscribes the SignalR tableDelta event over /hubs/stream and, for every ASSERT row (Z-set weight +1 — the RETRACT half of each LATEST BY update is skipped, or every measurement would be doubled), computes latency_ms = client wall clock at delta arrival − row.stage_ts. stage_ts is confirmed as the source event's own generation timestamp by reading the generator profile: shared/StreamsForge.AppCore/Generators/MarketDataProfiles.cs sets evt["stage_ts"] = evt[EventRecord.TimestampField] — i.e. it's copied verbatim from _ts, itself stamped with DateTimeOffset.UtcNow at the exact moment the synthetic event is generated. The number is therefore genuinely end-to-end: generation → ingress → the table's LATEST BY operator → delta publish → SignalR relay → this script's handler. First 10 seconds of each run are discarded as warmup, and any row whose stage_ts predates the run's own start is discarded (a backlog/rebuild artifact, not steady-state latency). Each run collects ≥500 samples or runs 90 seconds, whichever comes first. Orchestrated end-to-end by tools/bench/run-bench.sh: fresh isolated Orleans instance (temp --DataDir, ports 6199/6299) → run → full teardown → dapr/tools/reset.sh + dapr/tools/run.sh (5399/3599/4599) → run → full teardown.

The primary measurement originally hit a live bug — since root-caused and fixed (2026-07-19 addendum). Running this benchmark against a freshly booted Orleans instance found that its SignalR tableDelta relay (and, observed the same session, pipelineResult) never delivered to a subscribed client: zero events over repeated 15–90s windows across three different seeded tables (order_states, positions, leg_exposure), while the same tables' REST /api/tables/{id}/metrics showed deltasIn/deltasOut growing continuously the whole time. Root cause: a startup race in StreamBridgeService.ExecuteAsync — its one-time enumeration of Running pipelines/tables at boot could run before Program.cs's fire-and-forget registry-seeding callback populated the catalog, permanently starving those two subscriptions (source subscriptions self-heal via a 30s refresh; pipeline/table subscriptions never did). Confirmed via git-bisect against ad7652d (pre-plan-005) that this race predates the Dapr-port restructure entirely — a warm-boot repro at both commits worked fine, ruling out the originally-suspected cross-assembly Orleans serializer codegen (W1) as the cause. Fix: the bridge now awaits registry.EnsureInitializedAsync() itself before enumerating (idempotent, so the race becomes harmless). See dapr/ARCHITECTURE.md's "Known live bug" section for the full writeup and orleans/tests/StreamsForge.Host.Tests/StreamBridgeServiceStartupRaceTests.cs for regression coverage. The table below now shows real, re-measured Orleans numbers for the primary metric, collected after the fix.
RuntimeSignalSamplesp50p90p99maxmean
OrleanstableDelta (primary, order_states)379 (429 asserts seen)122 ms209 ms499 ms608 ms135.7 ms
DaprtableDelta (primary, order_states)397 (446 asserts seen)7 ms9 ms14 ms22 ms6.8 ms

A supplementary measurement is kept alongside the now-real primary metric: --signal sourceEvent on the raw order_events source (latency_ms = client wall clock at sourceEvent arrival − evt._ts) — the generator-tick→SignalR-relay hop only, not the full table-processing pipeline, useful as a comparison point against the primary signal's full pipeline hop:

RuntimeSignalSamplesp50p90p99maxmean
OrleanssourceEvent (supplementary, order_events)391 (436 asserts seen)36 ms99 ms122 ms212 ms46.7 ms
DaprsourceEvent (supplementary, order_events)396 (444 asserts seen)2 ms3 ms7 ms13 ms1.9 ms
The honest surprise: Dapr measured FASTER on every signal, contradicting this plan's own a-priori assumption. The plan going in expected Orleans to win on latency by a wide margin (in-process grain calls, no sidecar hops) and Dapr to lose 10–40× to batching cadence and sidecar round-trips. That is not what was measured, even after fixing the relay bug and getting real Orleans tableDelta numbers. Dapr's real tableDelta path — generator batch tick → publish → Redis → deliver → TableActor (itself a sidecar round-trip even for the actor invocation, per ActorProxyOptions.UseJsonSerialization) → publish → Redis → deliver → SignalR — came in at a real p50 of 7 ms on localhost; Orleans' equivalent full-pipeline path measured p50 122 ms, roughly 17× slower, with a notably long tail (p99 499 ms, max 608 ms) that Dapr's path doesn't show (p99 14 ms). The supplementary sourceEvent hop (no table processing, just generator→relay) tells the same story at smaller scale: Orleans p50 36 ms vs. Dapr p50 2 ms. One honest observation stands: stage_ts/_ts is stamped when the generator code actually runs, so batching-induced "held event" time is invisible to this metric on the Dapr side by construction.
Root cause, found and proven (post-005 investigation): the gap is two deliberate batching cadences on the Orleans path, not actor/runtime speed. (1) Memory streams are pull-based — Orleans' in-memory stream provider delivers via pulling agents that poll on StreamPullingAgentOptions.GetQueueMsgsTimerPeriod (default 100 ms), and the tableDelta path crosses two stream hops (sources → table, tableDelta → SignalR bridge) — the measured p90 ≈ 2× the poll period is that signature (Dapr's Redis pub/sub is push end to end). (2) The epoch flush — TableIngestGrain (plan 003's partitioned-dataflow machinery) buffers deltas and flushes epochs every 250 ms — but this applies only to Parallelism ≥ 2 tables; the benchmarked order_states is P=1 (classic path, no ingest grain), so on this benchmark the entire gap is contributor (1) alone, paid twice. Experiments on the same machine, same seeded order_states benchmark: stock cadences measure p50 115–125 / max ≈200 ms (the 005 run's 499/608 ms tail was machine load, not the pipeline); --Streams:PullPeriodMs 10 brings the same pipeline to p50 22 ms. Tunables now exist with defaults unchanged (Streams:PullPeriodMs, default 100; TABLES__FLUSHMS, default 250 — the stock settings favor throughput).
And the definitive answer — a switchable PUSH transport for the Orleans flavor (--Streams:Transport push). A drop-in IStreamProvider under the same provider name (every producer/consumer call site unmodified; pull remains the default and is byte-identical stock Orleans): publish is a non-blocking bounded-channel write, one pump task per subscriber delivers into the grain's own turn via a grain extension — per-key FIFO preserved, no call-cycle deadlocks by construction, backlog overflow counted and logged. Measured on the identical benchmark:
TransporttableDelta p50/p90/p99/maxsourceEvent p50/p90/p99/max
Orleans pull (stock)115 / 164 / 190 / 195 ms45 / 100 / 104 / 107 ms
Orleans push1 / 2 / 6 / 6 ms1 / 2 / 7 / 8 ms
Dapr (Redis pub/sub, from above)7 / 9 / 14 / 22 ms2 / 3 / 7 / 13 ms
With the transport actually push, Orleans lands at p50 1 ms — ahead of Dapr's 7 ms, which is where the "in-process should win" intuition finally cashes out: the original 17× deficit was never the actor model, it was the pull-based delivery default. The final scoreboard is symmetric and honest: each runtime's latency is dominated by its transport, not its actor machinery — Dapr pays sidecar hops (irreducible ≈7 ms), Orleans pays whatever its stream provider chooses to pay (100 ms polls stock, ≈1 ms with the push bus).

Delivery semantics

Orleans memory streams

In-process, single-silo delivery — effectively exactly-once within a silo lifetime: a stream push is a direct in-memory handoff to the subscribing grain, no broker, no redelivery mechanism to misfire. The tradeoff is the flip side of the same coin: it's a .NET-internal transport (the closed door the Dapr flavor's polyglot pub/sub opens) and doesn't survive a full silo restart mid-stream (no durable log to replay from — grain state persistence is separate from stream delivery).

Dapr pub/sub, at-least-once

Redis pub/sub is at-least-once by nature — a message can be redelivered (network blip, subscriber restart mid-processing, sidecar retry). This is the transport's honest nature, not papered over here: dapr/POLYGLOT.md documents that every ingress endpoint always returns 200 OK even on a malformed payload specifically because a non-2xx response is Dapr's redelivery trigger — an unconditional 200 avoids a poison message looping forever, but it also means the app itself, not the transport, is the only thing standing between "delivered twice" and "processed twice."

The idempotency gap — thought through honestly, not glossed over. A redelivered sf-table-delta envelope would double-apply its Z-set deltas: a retract(−1)/assert(+1) pair replayed a second time doesn't cancel out (Z-set weights aren't idempotent against replay — they're only correct under exactly-once application of each delta). Concretely: a duplicate delivery of a positions delta would apply the same quantity retraction/assertion twice, corrupting the running aggregate. Today this is mitigated only by Redis pub/sub's happy-path behavior (in practice, redelivery is rare absent an actual failure/restart mid-delivery) — there is no explicit dedup/idempotency key on any envelope in this plan. This is flagged here as a real production consideration for anyone taking the Dapr flavor further, not a solved problem: a production hardening pass would need per-envelope sequence-based dedup (the envelopes already carry monotonic seq fields that a consumer-side "last applied seq" check could use) before this flavor should sit in front of anything that can't tolerate an occasional double-applied delta.

Reuse scorecard

Counted with a plain line-count pass (find … | xargs cat | wc -l) over each project's non-generated .cs/.ts/.tsx source, excluding obj//bin/. This is a rough proxy for "how much of the platform is one codebase, not two" — not a precision metric, but the shape is unambiguous.

ComponentLinesShared?Role
shared/StreamsForge.Engine~7,750✓ sharedSQL compiler, both executors, Z-set ops, dataflow primitives
shared/StreamsForge.Contracts~620✓ sharedDTOs + facade interfaces + pub/sub envelopes
shared/StreamsForge.AppCore~2,740✓ sharedDynamic protobuf, search index, generator profiles, history retention, seed catalog
shared/StreamsForge.Api~1,120✓ sharedREST endpoints, SignalR hub, JWT, CORS/OpenAPI wiring
web/ (console SPA)~10,920✓ sharedReact 19 console — same web/dist served by both hosts
orleans/src/StreamsForge.Abstractions~240Orleans-onlyGrain interfaces (inherit the shared facades)
orleans/src/StreamsForge.Host~4,890Orleans-onlyGrains, Orleans facade adapters, Program.cs
dapr/src/StreamsForge.Dapr.Host~5,190Dapr-onlyActors, routers, supervisors, Dapr facade adapters, Program.cs
Shared server-side code (Engine+Contracts+AppCore+Api) vs. total server-side (shared + both runtime-specific projects)~12,230 / ~22,550 ≈ 54%
Shared code including the SPA vs. everything (server + SPA)~23,150 / ~33,470 ≈ 69%

Runtime-specific code is close to parity in size (Orleans ~5,130 total incl. Abstractions vs. Dapr ~5,190) — the Dapr flavor isn't a thin shim over the Orleans one, it's a genuinely separate actor/routing/supervisor implementation of the same contract, which is exactly what "same platform, two runtimes" is supposed to cost.

Polyglot processing

The Orleans flavor's only door for a non-.NET participant is the typed-gRPC client path — consume-only, and the client itself is .NET-toolchain-generated even though the wire format (proto3) is language-neutral. The Dapr flavor's pub/sub topics are the actual door: sf-sources is a plain HTTP/gRPC publish to the sidecar, JSON body, no .NET-specific serialization anywhere on the wire (dapr/POLYGLOT.md is the frozen contract). Three working processors prove this isn't theoretical:

Python — enricher

dapr/processors/python-enricher (~240 lines): subscribes sf-source-trades via its own sidecar, derives an enriched view, registers and publishes a brand-new source back into sf-sources — the derived source shows up in the console exactly like any seeded one.

TypeScript — bun consumer

dapr/processors/ts-consumer (~180 lines): subscribes sf-table-delta/sf-pipeline-out directly — no .NET runtime anywhere in this process.

Java — consumer

dapr/processors/java-consumer (~260 lines): buildable sample (gradle/maven), proving the contract is genuinely runtime-agnostic, not "Dapr .NET SDK plus a Python special case."

What this unlocks in a client conversation: a Java/Kotlin risk-analytics team, a Python data-science team, and a TypeScript tooling team can each attach a processor to the same running platform without touching the .NET codebase or waiting on a .NET release — they read/write plain JSON over a documented topic contract with frozen envelope shapes (additive evolution only, exactly like every other DTO in this project).

Operational story

Orleans

  • Clustering & placement: Orleans' own membership/placement provider — a well-understood but Orleans-specific operational model (this demo runs localhost clustering; production multi-silo would swap clustering/stream/storage providers, per the docs' Limitations section).
  • Local dev footprint: dotnet run. One process, nothing else to install or start.
  • State: one JSON file per grain under data/ — human-readable, rm -rf data/ to reseed.

Dapr

  • Sidecar + placement + scheduler: every instance runs with a Dapr sidecar; actor placement and reminders/timers are separate k8s-shaped services (dapr_placement, dapr_scheduler containers in local dev) — building blocks an ops team running Kubernetes already has patterns for.
  • Local dev footprint: dapr init (Redis + placement + scheduler containers) once, then dapr/tools/run.sh — heavier first-time setup, same one-command inner loop after.
  • State: Redis (state.redis, actorStateStore: true, keyPrefix: appid scoping) — dapr/tools/reset.sh SCANs and deletes this app's keys to reseed, the Dapr-flavor equivalent of deleting the Orleans data/ directory.

Ports, for anyone reproducing this: Orleans owns 5199 (REST/SignalR/SPA) + 5299 (gRPC); Dapr owns app 5399, gRPC reserved 5499 (not yet serving), sidecar HTTP 3599 / gRPC 4599. The two flavors never share a port and can run side by side on one machine, which is exactly how this page's benchmark was produced (sequentially, not concurrently, for CPU fairness).

Parity matrix (final, plan 005)

CapabilityOrleansDapr v1
REST CRUD + validate + rows/search/metrics + proto downloadfullfull (same shared endpoints)
JWT auth + RBAC + user adminfullfull (shared)
SignalR live events (all 6)fullfull (DaprStreamBridge)
Console SPA (same web/dist):5199:5399
Generators (6 profiles)fullfull (shared profiles; batched ticks)
Pipelines (windowed SQL, EMIT, nested queries)fullfull (shared Engine)
Tables classic P=1 (Z-sets, search, snapshots)fullfull
Row historyfullfull
Parallelism 2–16 / frontiers / arrangementsfulldescoped — rejected with clear error; frontierEpoch null; /api/meta/arrangements []
gRPC static + dynamic reflection + typed streamsfull (:5299)phase 2 (:5499 reserved; /api/meta/grpc shape kept, empty service list)
/docsfulldescoped (links to this Orleans-served page; see below)
Persistence / reseedJSON files / delete data/Redis / reset.sh
Polyglot pub/sub participation—the point: sf-sources ingress + sf-source-{name} egress

"Row history" moved from a W7 stub to full in this final pass — TableHistoryActor mirrors TableHistoryGrain's retention math field-for-field (see dapr/ARCHITECTURE.md's "Row history (W7-B)" section). This comparison page itself is served only as a static file today — see the callout below.

On this page's own serving. /docs maps exactly one file (docs/index.html) via StreamsForgeApiOptions.DocsFilePath — there is no directory-serving behind that route, so this file is not reachable at /docs/comparison.html or any other host-served URL today. It's linked from the docs sidebar as a same-directory relative link (comparison.html) and works when opened directly from the repo (a local file open, or any static file server pointed at orleans/docs/) — wiring an actual route is a one-line addition to shared/StreamsForge.Api that this plan deliberately leaves to a future change, per this wave's src-untouched scope.