Skip to content
GitHub

Streaming providers

Fleans queues custom tasks (ExecuteCustomTaskEvent) for gRPC workers through Orleans Streams. Script tasks and conditions do not use a stream: a workflow instance evaluates gateway and other conditions itself, and sends each script task straight to the script executor, which reports the result back — so they never wait for a queue poll. The stream provider is pluggable at startup: the default is Redis, and in-memory, Kafka and Azure Queue Storage providers ship alongside it.

The read model rides on the same provider: after an append that changes what lists show, an instance publishes InstanceAdvanced(instanceId, version) on the events.InstanceAdvanced namespace, one stream per instance, and a projector grain on the worker silos builds the read model from the instance’s event log. A provider that loses messages (in-memory across a restart, Redis without persistence) can leave a list row behind until that instance next activates.

Signal broadcasts ride on it too: a broadcast queues its deliveries on the events.SignalDelivery namespace, one stream per signal bucket, and the instances receive the signal from there at the pace set by Fleans:Signals:DeliveryConcurrency.

The provider name is read once at silo startup from configuration:

Configuration keyValuesDefault
Fleans:Streaming:Providerredis, memory, kafka, azurequeueredis
Fleans:Streaming:Kafka:Brokershost:port[,…]—
Fleans:Streaming:Kafka:ConsumerGroupstringfleans
Fleans:Streaming:Kafka:TopicPrefixstringfleans-
Fleans:Streaming:Kafka:QueueCountinteger8
Fleans:Streaming:Kafka:ReplicationFactorinteger3 (1 on a single-broker cluster)
Fleans:Streaming:AzureQueue:ConnectionStringAzure Storage connection string—
Fleans:Streaming:AzureQueue:AccountNameAzure Storage account name—
Fleans:Streaming:AzureQueue:QueueNamesJSON array of strings["fleans-stream-0"…"fleans-stream-7"]
Fleans:Streaming:AzureQueue:MessageVisibilityTimeoutTimeSpan string (e.g. 00:01:00)Azure SDK default (30 s)

Match is case-insensitive. Any other value throws ArgumentException at startup — add a new case to FleanStreamingExtensions.AddFleanStreaming if you ship another provider.

For azurequeue, exactly one of ConnectionString or AccountName must be set. ConnectionString is used for local dev (Azurite). AccountName enables Managed Identity (DefaultAzureCredential) for production Azure deployments.

Backed by AddMemoryStreams. Zero infrastructure. Events are lost on silo crash: in-flight stream messages live only in the silo’s memory.

Right for: local development, all default tests, and single-silo deployments where you accept the event-loss profile of a silo crash — the single-PostgreSQL layout. The engine’s default is redis.

Backed by an in-repo Fleans.Streaming.Kafka adapter built on Confluent.Kafka 2.6.1 (librdkafka 2.6.x bundled).

Right for: production rollouts that need event durability beyond a single silo, CI environments that want broker parity with production, and topologies where a non-Orleans consumer may join the topics later (note: the codec is currently the Orleans codec — see limitations below).

azurequeue — opt-in cross-silo durability (Azure-native)

Section titled “azurequeue — opt-in cross-silo durability (Azure-native)”

Backed by Microsoft.Orleans.Streaming.AzureStorage — the first-party Microsoft Orleans adapter for Azure Queue Storage. This is a thin wrapper, not a custom adapter, so behaviour, retry semantics, and dead-letter handling are governed by the Microsoft package.

Right for: Azure-native deployments that need cross-silo event durability without running Kafka. Uses Azure Queue Storage (cheaper than Service Bus at scale; Orleans StreamSequenceToken provides per-queue ordering). In dev mode, Aspire auto-provisions Azurite so no Azure subscription is needed.

For auth options: set ConnectionString for Azurite or an explicit connection string; set AccountName to use DefaultAzureCredential (Managed Identity, recommended for Container Apps).

Plaintext is the default. To connect to a broker that requires authentication or encryption, set these keys under Fleans:Streaming:Kafka on every Fleans workload (as environment variables: Fleans__Streaming__Kafka__<Key>). An invalid combination — a SASL protocol without a mechanism, a certificate without its key, TLS files with a plaintext protocol — stops the silo at startup with an error naming the setting.

KeyValuesNotes
SecurityProtocolPlaintext (default), Ssl, SaslPlaintext, SaslSslManaged services almost always use SaslSsl.
SaslMechanismPlain, ScramSha256, ScramSha512Required with SaslPlaintext / SaslSsl.
SaslUsername / SaslPasswordRequired with Plain and the Scram* mechanisms.
SslCaLocationPEM file pathCA that signed the broker certificate. Omit to trust the OS store.
SslCertificateLocation / SslKeyLocationPEM file pathsClient certificate and key for mutual TLS; set both.
SslKeyPasswordOnly for a passphrase-protected key.

Use absolute paths for the Ssl*Location files — they resolve against the silo’s working directory. OAUTHBEARER and Amazon MSK IAM authentication need a token callback in code, so the released images can’t use them; connect to MSK through its SASL/SCRAM or TLS listener instead.

Add the keys to every fleans-* service in your docker-compose.override.yaml, for example SASL/SCRAM over TLS:

services:
fleans-core:
environment: &kafka
Fleans__Streaming__Provider: Kafka
Fleans__Streaming__Kafka__Brokers: "broker.example.com:9093"
Fleans__Streaming__Kafka__SecurityProtocol: SaslSsl
Fleans__Streaming__Kafka__SaslMechanism: ScramSha512
Fleans__Streaming__Kafka__SaslUsername: fleans
Fleans__Streaming__Kafka__SaslPassword: ${KAFKA_PASSWORD}
fleans-management:
environment: *kafka
fleans-mcp:
environment: *kafka
fleans-worker:
environment: *kafka

What the Kafka adapter still leaves to you. Durability is production-typical by default: topics are created with ReplicationFactor=3 (1 on a single-broker cluster, with a startup warning on a partially provisioned one), and the producer is idempotent with acks=all.

GapWhy it hurtsWorkaround
At-least-once delivery (offset commit after handler success)Silo crash between handler success and offset commit → event replaysSafe by design — every consumer routes to a WorkflowInstance method guarded by HasActiveEntry. Do not remove that guard. See Delivery contract
Dead-letter topic off by defaultA record that keeps failing is dropped after DeliveryRetryWindow with only an error logSet EnableDeadLetterQueue=true — see Poison records
One partition per topicRecords are ordered by offset, which Kafka orders only within a partition, so a topic must not be splitRaise QueueCount for parallelism — each queue is its own topic. Startup fails on an existing topic with more than one partition; never kafka-topics --alter --partitions. See Tuning throughput
No schema registry integrationCodec is the Orleans codec; non-Orleans consumers cannot decode the streamTreat Kafka topics as silo-internal
Consumer lag is a metric, not a health checkA lagging consumer doesn’t turn the silo unhealthyAlert on the fleans.kafka.consumer.lag gauge — see Observability

Manual test plan #35 exercises the happy path against a single-broker Aspire-provisioned fleans-kafka resource — it validates that the at-least-once contract holds across a silo restart, but it does not test any production failure mode.

Production-readiness gaps — Azure Queue Storage

Section titled “Production-readiness gaps — Azure Queue Storage”

The Azure Queue provider is backed by the Microsoft-maintained Microsoft.Orleans.Streaming.AzureStorage package, which covers several production concerns automatically.

ConcernStatus
Managed Identity (AccountName path)✅ Covered — uses DefaultAzureCredential
Message retry / poison-message handling✅ Covered — MS provider retries up to configurable limit; exhausted messages move to *-poison queue automatically
OTLP monitoring✅ Covered — Orleans Streams telemetry emitted via Orleans.Telemetry; no extra wiring needed
Managed Identity token rotation✅ DefaultAzureCredential handles refresh transparently
Cross-queue message ordering⚠️ Per-queue FIFO only — ordering across the 8 default fleans-stream-* queues is not guaranteed (same trade-off as Kafka partitions)

Manual test plan #44 exercises the happy path with Azurite.

The Kafka adapter checkpoints offsets after the consumer-side handler completes. On a silo crash between handler success and offset commit, the next consumer instance resumes from the last committed offset, which means the in-flight event will replay.

Offsets commit only after the handler succeeds; a crash before commit replays the event and the HasActiveEntry guard turns the duplicate into a logged no-op. Open interactive diagram ↗

The replay is safe because every Fleans consumer eventually routes to a method on WorkflowInstance (CompleteActivity, FailActivity, …) that opens with a stale-callback guard:

if (!State.HasActiveEntry(activityInstanceId))
{
LogStaleCallbackIgnored(...);
return;
}

Once the activity instance has completed once, its entry leaves HasActiveEntry, so a second delivery is a logged no-op. Do not remove that guard without re-reading this page — it is what makes the at-least-once contract safe for the workflow engine.

Replays also happen without a crash. Each Kafka topic is read by the silo the Orleans queue balancer gives it to; the silos do not join the Kafka consumer group. When a queue moves to another silo (a silo starts or stops), the old and new owners can both read it for a moment, so a few events are delivered twice. The guard handles these the same way. A custom task handler with external side effects should be idempotent for the same reason.

When a subscriber keeps throwing on a record, Orleans retries delivery for up to DeliveryRetryWindow (unset by default: MaxEventDeliveryTime, 6 minutes, applies), then gives up and moves on to the next record. By default that record is dropped with only an error log.

Set Fleans__Streaming__Kafka__EnableDeadLetterQueue=true to keep it instead. Each such record is then published, unchanged, to a dead-letter topic named after its source topic (fleans-0 → fleans-0-dlq). The engine creates the dead-letter topics at startup alongside the regular ones.

SettingDefaultWhat it does
Fleans__Streaming__Kafka__EnableDeadLetterQueuefalseRoute records whose subscriber keeps failing, and records a silo cannot deserialize, to the dead-letter topic instead of dropping them.
Fleans__Streaming__Kafka__DeadLetterTopicSuffix-dlqSuffix appended to each source topic. The silo refuses to start if the suffix would make a dead-letter topic name equal a regular topic (for example 0: fleans-1 + 0 = fleans-10).
Fleans__Streaming__Kafka__DeliveryRetryWindowunset (MaxEventDeliveryTime, 00:06:00)How long to keep retrying a failing record before giving up (hh:mm:ss), overriding MaxEventDeliveryTime for Kafka. The stream stays blocked for that subscriber while it retries. Keep it longer than your slowest custom task, or that task’s event is skipped and the task never runs.

Each dead-letter record keeps the original record’s headers and adds these:

HeaderValue
x-fleans-original-topic / -partition / -offsetWhere the record came from.
x-fleans-failure-reasondelivery-failed (a subscriber kept throwing) or deserialization-failed (the silo reading the queue could not decode the record).
x-fleans-failure-time-utcISO-8601 timestamp.
x-fleans-stream-idOrleans stream ID (delivery-failed only).
x-fleans-subscription-idThe subscription whose handler kept failing (delivery-failed only).
x-fleans-exceptionException type and message (deserialization-failed only).

A record whose bytes cannot be deserialized is not retried: it is skipped right away and logged at Error — EventId 11113 when it goes to the dead-letter topic, 11107 when the DLQ is off and it is dropped. Two causes are known: something other than the engine wrote to the topic, or a silo reading the queue runs an older Fleans version than the silo that wrote the record. Every silo that registers the stream provider reads a share of the queues — keep them on one Fleans version. Custom-task workers read no queues.

Delivery to the dead-letter topic is at-least-once: a record that fails for several subscribers is published once per subscriber, each copy with its own x-fleans-subscription-id. Deduplicate on the x-fleans-original-* headers. The whole Kafka record is dead-lettered, so replaying it also redelivers any events in it that had already succeeded — safe, because of the at-least-once contract above.

Inspect the topic with any Kafka client, for example:

Terminal window
kafka-console-consumer.sh --bootstrap-server <broker> --topic fleans-0-dlq \
--from-beginning --property print.headers=true

To retry a record once the cause is fixed, produce its value back to x-fleans-original-topic. Redelivery is safe: the engine ignores events for activities that have already completed (see the at-least-once contract above). Every dead-lettered record is logged at Warning with EventId 12000.

The dead-letter topic is best-effort, not a guarantee. If publishing to it is not confirmed within 30 seconds (EventId 12001, Error), the record may still arrive later, or may be lost. If the broker no longer holds the original record (EventIds 12004–12006), the record is lost: the engine has already moved past it. Connection, authentication or TLS problems while re-reading the record are logged as EventId 12007. Alert on EventId 12001. A record is also lost if the silo crashes while Orleans is still retrying it (#813).

KafkaQueueAdapterFactory.CreateAdapter() runs a one-shot topic-ensure step on first activation:

  1. Build the expected topic list ({TopicPrefix.TrimEnd('-')}-{0..QueueCount-1}).
  2. AdminClient.GetMetadata to discover what already exists on the broker.
  3. Fail startup if an expected topic already exists with more than one partition — recreate it with one (Kafka cannot shrink a topic).
  4. AdminClient.CreateTopicsAsync for missing topics with one partition and the configured ReplicationFactor.
  5. TopicAlreadyExists and NoError are both treated as success (a peer silo may have just created the topic).

The broker-side allow.auto.create.topics flag is not required — it is documented here as a fallback for self-managed clusters that prefer it, but managed clusters (Confluent Cloud, MSK) disable it by default and the client-side path is what we ship.

Set Fleans__Streaming__Provider on every fleans-* workload via your deployment tool. The engine accepts four values: Redis (default), Memory, Kafka, AzureQueue. Both the Docker Compose bundle and the Helm chart ship with sensible defaults — you only need an explicit switch when moving off the default.

The bundle ships with Redis as the default streaming provider. You only need an override file to switch away from Redis — for the production-default Redis behaviour, no edits are required.

Drop a docker-compose.override.yaml next to the bundle’s docker-compose.yaml with the same env var on every fleans-* service:

services:
fleans-core:
environment:
Fleans__Streaming__Provider: Memory # or Kafka, AzureQueue
fleans-management:
environment:
Fleans__Streaming__Provider: Memory
fleans-mcp:
environment:
Fleans__Streaming__Provider: Memory
fleans-worker:
environment:
Fleans__Streaming__Provider: Memory

For Kafka, add Fleans__Streaming__Kafka__Brokers: "kafka:9092" (replace with your broker host:port). For Azure Queue, add Fleans__Streaming__AzureQueue__ConnectionString: "<your-connection-string>". Then docker compose up -d from the bundle directory — Compose merges the override automatically.

This streaming override is a pure service-level env addition (unlike the worker override in Writing custom tasks, which depends on the fleans_default network being created by an engine up first). You can docker compose down && docker compose up -d from the bundle directory at any time to apply the change — engine-first ordering is not required.

(For the source-tree dev workflow — only relevant if you cloned the engine repo — see the engine CLAUDE.md’s Things to Know section for the AppHost-side FLEANS_STREAMING_PROVIDER knob.)

Redis (default): Reuses the same Redis instance the engine already runs for clustering, so stream events cost zero extra infrastructure. Uses the third-party Universley.OrleansContrib.StreamsProvider.Redis package (MIT, Orleans 10.x-compatible), pinned in Fleans.ServiceDefaults.csproj. Production requirement: enable Redis persistence (AOF or RDB) — without it, a Redis restart loses in-flight messages. The chart’s bundled fleans-redis enables AOF by default; see Self-host with Helm for the Redis subchart values.

Memory: No external infrastructure. Drops in-flight events on silo restart — single-silo debug-only. Suitable for local dev and CI smoke tests; never for production.

Kafka: Supply your own Kafka cluster — the chart’s streaming.kafka.brokers value takes a Kafka bootstrap-server string; for SASL or TLS brokers see § Kafka security. For an in-cluster Kafka, the Bitnami Kafka Helm chart is the conventional choice. The silo forwards Fleans__Streaming__Provider=Kafka + Fleans__Streaming__Kafka__Brokers to the Kafka producer.

AzureQueue: Supply an Azure Queue Storage connection string — see Microsoft’s Azure Storage connection-string docs for the format. For local dev, run Azurite as a sibling Compose service and point the connection string at it. The silo forwards Fleans__Streaming__Provider=AzureQueue + Fleans__Streaming__AzureQueue__ConnectionString to the Azure Queue producer.

Connection multiplexer aliasing (Redis only)

Section titled “Connection multiplexer aliasing (Redis only)”

The third-party Redis stream provider resolves a non-keyed IConnectionMultiplexer from DI. Aspire registers it as a keyed service (AddKeyedRedisClient("orleans-redis")). FleanStreamingExtensions.AddRedisStreams bridges the two with TryAddSingleton<IConnectionMultiplexer>(...) — reusing the same connection pool, no duplicate sockets. If another component needs a different non-keyed multiplexer, register it explicitly before AddFleanStreaming runs and TryAddSingleton will defer.

Fleans subscribes to streams only through implicit subscriptions: each handler grain declares the stream namespaces it consumes, and Orleans routes events by that declaration. So there is no subscription state to store, and no PubSubStore grain storage to configure. Every silo in the cluster, custom-worker hosts included, uses this mode through AddFleanStreaming; a host that registers its stream provider by hand must call ConfigureStreamPubSub(StreamPubSubType.ImplicitOnly) too.

What changes when you opt into Kafka is event durability — the bytes flowing between WorkflowEventsPublisher.Publish and a custom-task handler’s OnNextAsync. With the in-memory provider, those bytes live in the silo’s process memory. With Kafka, they land in a broker topic before the handler runs.

Upgrading from v0.10.0 or earlier with Kafka: stop every silo first. Newer silos read their topics without joining the Kafka consumer group, and they must not share ConsumerGroup with older silos that still join it: the broker rejects the new silos’ offset commits, and both versions read the same events. Scale every silo deployment to zero before upgrading; a rolling update would run both versions together. Changing ConsumerGroup to avoid the stop is no shortcut: a group with no committed offsets reads every topic again from the start.

Confluent.Kafka 2.6.1 / librdkafka 2.6.x are the supported versions. The adapter targets net10.0 (matching every other silo csproj in this repo).

Throughput across the queue-backed providers is governed by two independent classes of knobs. The first is an Orleans-level concept shared by every provider; the second is Kafka-specific and does not have analogues elsewhere.

Orleans parallelism (all queue-backed providers)

Section titled “Orleans parallelism (all queue-backed providers)”

Fleans:Streaming:Redis:TotalQueueCount, Fleans:Streaming:Kafka:QueueCount, and the length of Fleans:Streaming:AzureQueue:QueueNames all set the same thing: the number of Orleans pulling-agent grains that activate across the cluster. Stream IDs hash across this many partitions; each partition is consumed by one pulling agent. More partitions = more parallel consumption.

ProviderKnobDefaultNotes
RedisFleans__Streaming__Redis__TotalQueueCount8Configurable as of v0.2.0 (#567).
KafkaFleans__Streaming__Kafka__QueueCount8Was 1 before v0.2.0 (#567) — call out as a behavior change for deployments that left it unset.
AzureQueueFleans__Streaming__AzureQueue__QueueNames__0..N8 entries (fleans-stream-0..7)No scalar count — tuning above OR below 8 requires populating the explicit list by hand. Mid-deployment length changes rehash Stream IDs across queues.

Use the same value on every silo. Every silo must run with the same queue count (and, for AzureQueue, the same QueueNames list). Orleans does not check this: silos with different values route the same stream to different queues, and workflows stall with no error in the logs. Set the value explicitly in shared configuration rather than per host.

Sizing heuristic. Start with max(8, 2 × silo_count × cpu_per_silo) for high-volume deployments and measure with the Orleans dashboard (wired at Fleans.Api/Program.cs) before raising further. In a multi-silo deployment, agents distribute across silos via the hash ring — total cluster-wide count = the configured value, per-silo count ≈ ceil(value / silo_count).

Rehash caveat (all providers). Bumping the count rehashes Stream IDs across queues. Consistent with the project’s pre-v1 stance, expect in-flight workflow stalls across the bump window — no formal drain procedure is provided. After the bump, new workflow instances distribute over the new queue count; in-flight instances that straddle the rehash may stall until reactivation picks them up on the new queue assignment.

Tradeoff. Each partition adds a pulling-agent grain per silo plus provider-side resource overhead (a Redis connection, a Kafka consumer, an AzureQueue receiver lease).

Production caveats already documented elsewhere. Redis streaming requires AOF or RDB persistence — see Provider notes above; Kafka requires its own cluster lifecycle ops — see Production-readiness gaps.

KnobDefaultWhat it does
Fleans__Streaming__PollPeriod00:00:00.100 (Orleans default)How often each pulling agent reads its queue. A stream event waits up to one period before delivery, so it bounds the per-hop latency of custom tasks and read-model updates. A shorter period lowers that wait and polls the backend (Redis, Kafka, Azure Queue) more often. Must be a positive TimeSpan.

Script tasks and conditions are not affected: they do not travel on a stream.

KnobDefaultWhat it does
Fleans__Streaming__MaxEventDeliveryTime00:06:00How long a silo keeps retrying one stream event before skipping it for good. Nothing is logged when an event is skipped and the instance stays stuck, so the window must be longer than your slowest custom task. Set the same value on engine silos and plugin hosts. Must be a positive TimeSpan.
Fleans__Streaming__Redis__MaxStreamLength100000Maximum entries kept per Redis stream (one stream per queue). Redis trims by length whether or not an entry was delivered, so a backlog longer than this loses events. Size it above the largest backlog you expect (peak events per second per queue × the longest consumer outage) with headroom; memory grows with length × TotalQueueCount.

The trade-off of a long window: a subscriber that keeps failing holds its queue for the whole window before the event is dropped (or, on Kafka with EnableDeadLetterQueue, dead-lettered).

Fleans also sets how long a silo keeps a stream it has stopped seeing events on to twice the window (12 minutes by default). Every custom task has its own stream, so this bounds the per-stream memory a silo holds; a larger window raises it too.

Every topic has exactly one partition: Fleans orders records by offset, which Kafka orders only within a partition. Scale with QueueCount — each queue is its own topic, so it sets both the Orleans consumer count and the broker-side partition count. If a partition is added to a live topic, running silos still read it (so no record is lost) but log a warning (EventId 11118), and the next startup fails until the topic is recreated.

KnobDefaultWhat it does
Fleans__Streaming__Kafka__ReplicationFactor3Per-topic replication factor at topic creation. Capped at the live broker count: a single-broker cluster gets 1, and a cluster with fewer brokers than the setting logs a warning at startup.
  • Observability — health checks, metrics, logging, tracing, dashboards, alerting
  • Deployment — how to wire Fleans:Streaming:Provider / Fleans:Streaming:Kafka:Brokers into Docker Compose, Kubernetes, and bare-VM deployments.
  • Self-Hosting on Kubernetes — Kafka opt-in on the Helm chart.