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:
| Environment | Orleans console | Dapr 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
daprdsidecar 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 flavor | Dapr flavor |
|---|---|---|
| .NET-only, one deploy unit | ✓ natural fit — no sidecar, no extra moving parts | works, 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 003 | descoped 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 codegen | reserved, 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
Dapr — sidecar'd actors + Redis pub/sub
sf-sources topicStructurally, 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.
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.| Runtime | Signal | Samples | p50 | p90 | p99 | max | mean |
|---|---|---|---|---|---|---|---|
| Orleans | tableDelta (primary, order_states) | 379 (429 asserts seen) | 122 ms | 209 ms | 499 ms | 608 ms | 135.7 ms |
| Dapr | tableDelta (primary, order_states) | 397 (446 asserts seen) | 7 ms | 9 ms | 14 ms | 22 ms | 6.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:
| Runtime | Signal | Samples | p50 | p90 | p99 | max | mean |
|---|---|---|---|---|---|---|---|
| Orleans | sourceEvent (supplementary, order_events) | 391 (436 asserts seen) | 36 ms | 99 ms | 122 ms | 212 ms | 46.7 ms |
| Dapr | sourceEvent (supplementary, order_events) | 396 (444 asserts seen) | 2 ms | 3 ms | 7 ms | 13 ms | 1.9 ms |
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.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).--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:
| Transport | tableDelta p50/p90/p99/max | sourceEvent p50/p90/p99/max |
|---|---|---|
| Orleans pull (stock) | 115 / 164 / 190 / 195 ms | 45 / 100 / 104 / 107 ms |
| Orleans push | 1 / 2 / 6 / 6 ms | 1 / 2 / 7 / 8 ms |
| Dapr (Redis pub/sub, from above) | 7 / 9 / 14 / 22 ms | 2 / 3 / 7 / 13 ms |
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."
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.
| Component | Lines | Shared? | Role |
|---|---|---|---|
shared/StreamsForge.Engine | ~7,750 | ✓ shared | SQL compiler, both executors, Z-set ops, dataflow primitives |
shared/StreamsForge.Contracts | ~620 | ✓ shared | DTOs + facade interfaces + pub/sub envelopes |
shared/StreamsForge.AppCore | ~2,740 | ✓ shared | Dynamic protobuf, search index, generator profiles, history retention, seed catalog |
shared/StreamsForge.Api | ~1,120 | ✓ shared | REST endpoints, SignalR hub, JWT, CORS/OpenAPI wiring |
web/ (console SPA) | ~10,920 | ✓ shared | React 19 console — same web/dist served by both hosts |
orleans/src/StreamsForge.Abstractions | ~240 | Orleans-only | Grain interfaces (inherit the shared facades) |
orleans/src/StreamsForge.Host | ~4,890 | Orleans-only | Grains, Orleans facade adapters, Program.cs |
dapr/src/StreamsForge.Dapr.Host | ~5,190 | Dapr-only | Actors, routers, supervisors, Dapr facade adapters, Program.cs |
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_schedulercontainers in local dev) — building blocks an ops team running Kubernetes already has patterns for. - Local dev footprint:
dapr init(Redis + placement + scheduler containers) once, thendapr/tools/run.sh— heavier first-time setup, same one-command inner loop after. - State: Redis (
state.redis,actorStateStore: true,keyPrefix: appidscoping) —dapr/tools/reset.shSCANs and deletes this app's keys to reseed, the Dapr-flavor equivalent of deleting the Orleansdata/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)
| Capability | Orleans | Dapr v1 |
|---|---|---|
| REST CRUD + validate + rows/search/metrics + proto download | full | full (same shared endpoints) |
| JWT auth + RBAC + user admin | full | full (shared) |
| SignalR live events (all 6) | full | full (DaprStreamBridge) |
Console SPA (same web/dist) | :5199 | :5399 |
| Generators (6 profiles) | full | full (shared profiles; batched ticks) |
| Pipelines (windowed SQL, EMIT, nested queries) | full | full (shared Engine) |
| Tables classic P=1 (Z-sets, search, snapshots) | full | full |
| Row history | full | full |
| Parallelism 2–16 / frontiers / arrangements | full | descoped — rejected with clear error; frontierEpoch null; /api/meta/arrangements [] |
| gRPC static + dynamic reflection + typed streams | full (:5299) | phase 2 (:5499 reserved; /api/meta/grpc shape kept, empty service list) |
/docs | full | descoped (links to this Orleans-served page; see below) |
| Persistence / reseed | JSON 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.
/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.