- Series: redb ecosystem
Say "enterprise .NET stack" out loud and most people picture a menagerie: a database from one vendor, a bus from another, an ORM with migrations, a separate orchestrator, something for observability — and a layer of glue between all of it that you write yourself and then debug yourself at 2 a.m.
We took the other road. For the past six months we've been building this as one coherent ecosystem: a typed store, redb, on top of Postgres/MSSQL/SQLite; an integration engine, redb.Route (our take on Apache Camel for .NET); and a runtime, redb.Tsak, with a dashboard, hot-reload, and clustering. Three layers, one codebase, one style.
Today we shipped 3.3.0 — a synchronized bump across the whole ecosystem. This isn't a "we added a couple of features" release. It's the one where we fixed the things that were quietly broken under load (that's the headline, honestly), added two new transports, closed the loop on RAG for LLMs, and made concurrency work on any source. Code below, no fluff.
And the part everyone asks about first: as of 3.3.0, every Pro package is free. No licenses, no keys, no sign-up — you install the package and use it. Change tracking, bulk ops, the advanced cache, analytics, the Tsak cluster with its coordinator and failover — all in the box, same in dev and in prod. dotnet add package redb.Postgres.Pro and it just works. No paywall, no license server, nothing to activate.
Part of the redb / redb.Route series — recent posts first:
- Leaving MassTransit for a Camel state of mind: the Kafka connector, Scatter-Gather, and transactions
- Apache Camel for .NET, dissected: the HTTP connector with no ASP.NET MVC + the Content-Based Router
- redb.Route — Apache Camel for .NET: 22 transports, 30+ EIP patterns, compiled DSL
Sources: github.com/redbase-app. About the database itself: redb.ru.
The three layers, in one breath
So the rest of this makes sense, here's the cast:
-
redb — a typed store for .NET. You write a plain POCO, tag it with an attribute, and work with it through full LINQ, server-side, with no migrations and no
Include. Providers: Postgres, MSSQL, SQLite. Free and Pro editions (change tracking, bulk, cache, analytics). -
redb.Route — an integration engine in the Camel spirit: a route DSL (
From(...).….To(...)), 30+ connectors (Kafka, RabbitMQ, HTTP, gRPC, S3, LLM, and so on), enterprise integration patterns, transactions, telemetry. The glue — except it's a real engine with tests, not something you hand-rolled over a weekend. -
redb.Tsak — a runtime that takes redb.Route routes and turns them into a production service: a dashboard, hot-reloadable modules (
.tpkg), live context management, metrics, a cluster with a coordinator and failover. Ships as NuGet packages, Docker images, and standalone archives.
All the code samples are in English (they always were); everything's in the repo, so you can reproduce it.
To make the bottom layer concrete rather than a claim — here's what redb looks like in practice. A class, an attribute, and then full LINQ that runs server-side, no migrations, no Include:
[RedbScheme]
public class Note
{
public string Tag { get; set; } = "";
public string Text { get; set; } = "";
}
// write
await redb.SaveAsync(new RedbObject<Note> { Props = new() { Tag = "work", Text = "hello" } });
// read — server-side query, the param comes straight from your code, no hand-written JOINs
var notes = await redb.Query<Note>()
.Where(n => n.Tag == "work")
.ToListAsync();
The schema comes from SyncSchemeAsync<Note>() — generated off the class itself, no migration files. This same layer stores both the knowledge-base chunks from the RAG example later on and your own domain objects, exactly alike.
redb.Route: what's new
Starting with the engine, because that's where most of the meat is.
Two new transports: Amazon SQS/SNS and Telegram
redb.Route.Sqs — an AWS queue and topic in one package
One package, two schemes: sqs:// (queue: consumer + producer) and sns:// (topic: publisher + SNS→SQS fan-out). Under the hood it's the native AWS SDK for .NET v4 — not a reinvented client.
using redb.Route.Sqs.Fluent;
// Consumer: 5 competing workers, long-poll, at-least-once (delete on success — ack is automatic)
From(Sqs.Queue("orders").WaitTimeSeconds(20).ConcurrentConsumers(5))
.Process(ex => Handle(ex.In.Body));
// Publish an event to an SNS topic (fans out to subscribed queues)
From("timer://tick?period=5000")
.SetBody(_ => BuildEvent())
.To(Sns.Topic("order-events"));
The consumer does long-polling, ConcurrentConsumers(N) (real competing loops), a visibility timeout you can extend while a handler runs, transactional ack via .Transacted(), and FIFO. The producer sends single and batched messages with FIFO group/dedup ids; the SNS publisher handles subject, message structure, FIFO, and an SNS→SQS auto-subscription. Point serviceUrl= at LocalStack or ElasticMQ and it all works locally; the full AWS credential chain is honored, and W3C trace context rides along across the hop. Details in redb.Route.Sqs/README.md.
redb.Route.Telegram — a bot as a route
The telegram:// scheme, built on Telegram.Bot. The consumer is a long-poller (receive, a single getUpdates stream per token); the producer covers send / document / photo / edit / delete / answer.
// Echo bot: take an update, reply into the same chat
From(Tg.Receive(token))
.Process(ex => ex.Out.Body = $"You said: {ex.In.Body}")
.To(Tg.Send(token).ChatId(Header(TelegramHeaders.ChatId)));
Out of the box: it honors the 429 retry_after contract (waits and retries instead of falling over), validates parseMode (throws on a typo rather than quietly shipping raw markup), supports inline/reply keyboards (WithInlineKeyboard / WithReplyKeyboard), a webhook-unpack pipeline (UnpackTelegramUpdate), and a fluent DSL (Tg.Receive/Send/Document/...). Delivery is at-most-once — Telegram advances the update offset the moment it hands you the update, and that's documented, not a surprise. Parallelism comes from .Threads(N) (more on that below), with consumer telemetry and producer spans.
Two transports people actually run: SQS for anything living on AWS, Telegram for ChatOps and bots. Both are tested against emulators.
RAG, end to end: knowledge://, embed:// and .Knowledge()
redb.Route.Llm already had a universal OpenAI-compatible provider and a native Anthropic one. In 3.3.0 we closed the RAG loop — from ingesting documents to answering grounded on them, all inside routes.
What landed:
-
knowledge://(an ingest scheme) — a producer that takes the message body as a document, chops it into chunks (deterministic character windows with overlap), embeds each one if anIEmbeddingProvideris registered, and upserts into theIKnowledgeStore. Loading documents becomes a route:
From("file://docs?include=*.md").To("knowledge://handbook");
Chunk ids are {docId}#{index}, so re-ingesting the same document replaces its chunks in place instead of piling up duplicates.
embed://(embeddings as a step) — the mirror image ofllm://:To("embed://openai")turns the message body (a text, or a batch of texts with order preserved) into a vector onOut.Body. The URI host names a connection factory, so different routes pick different embedding models by name..Knowledge(collection, k)(a retrieval DSL step) — pulls the top-K chunks for the current message and injects them into the system prompt, so the next.To("llm://…")answers off them:
From("kafka://questions")
.Knowledge("handbook", k: 5)
.To("llm://claude");
Semantic when an IEmbeddingProvider is around (embed the query → cosine SearchAsync), keyword otherwise (SearchTextAsync, a server-side LIKE over an indexed column). No store wired or nothing retrieved? The step is a no-op and never breaks the pipeline.
-
knowledge_search(a ready-made tool) — an.AsLlmToolroute over the search, so the agent can hit the knowledge base itself: input{query, top_k?, collection?}→{results:[…]}. You can pin the collection, which makes the model's argument moot — that's how you fence an agent into a single tenant or document set.
The full loop: knowledge:// ingest → embeddings → keyword/semantic search → the knowledge_search tool or .Knowledge() injection. And no external vector store unless you want one — the chunks live in redb, and search is just a server-side query.
We also killed an annoying one along the way: several of the LLM connector's JSON serializers used the default encoder, which escapes every non-ASCII character as \uXXXX. On tool results that roughly 6×'d the tokens the model saw for Cyrillic/CJK content (and could leak literal \u… into the text); in the knowledge store it buried chunk text so the new keyword LIKE couldn't match a raw non-ASCII query. Switched to UnsafeRelaxedJsonEscaping everywhere it mattered. If your data isn't pure ASCII, you'll feel this in both the token bill and the output.
.Threads(N) — concurrency on any source
There's a Camel-style processing-concurrency stage now: From(...).Threads(N)…EndThreads() caps a section of the route at N. The point: even a strictly serial source (a poll consumer, MQTT, a single request thread) can now chew through up to N messages at once — no named seda:// endpoint required.
From("mqtt://sensors") // serial by nature
.Threads(8) // up to 8 in flight
.Process(ex => HeavyWork(ex))
.EndThreads();
The nice part is that it's adaptive to the exchange pattern. For InOnly (fire-and-forget) it's a hand-off: clone plus a worker pool, a transaction boundary just like .To("seda://"). For InOut it runs the body inline on the same exchange under a SemaphoreSlim gate, so the reply — on Out or In — survives intact, request/reply (RPC) works through it, and the ambient transaction flows into the inline body. You get .MaxQueueSize(n) and .EnqueueTimeout(TimeSpan). Ordering isn't preserved at N > 1 — that's the price of parallelism, and we say so plainly.
The big one: ConcurrentConsumers(N) finally does something
Now the important — and frankly embarrassing — part. Brace yourself.
ConcurrentConsumers(N) was silently giving you no parallelism on the brokers. The option existed, you passed a number, it even sized an internal SemaphoreSlim — and no actual concurrency came out. The cause differed per connector; the symptom was always the same: the consumer processed one message at a time, unacked climbed to the prefetch limit, and the route never kept up.
What it was, and what it is now:
-
RabbitMQ. The channel was built with the three-argument
CreateChannelOptions(...)ctor, leaving the fourth parameter (consumerDispatchConcurrency) at its compile-time default — and inRabbitMQ.Client7.2.1 that default is1, notnull. A non-null per-channel value overrides the connection-level setting, so every channel was pinned to serial dispatch and the value from the URI/factory was quietly dropped. NowConcurrentConsumersis the single knob for consumer parallelism: the channel always opens with an explicit dispatch concurrency. (redb.Route.RabbitMQ3.2.2.) -
Kafka. Plus a new
EnableAutoCommitoption (defaulttrue) to bring offset-settle in line with RabbitMQ's post-process ack — and a fix for the transacted producer that was throwingLocal: Erroneous stateon the deferred send. (redb.Route.Kafka3.2.1.) -
AMQP 1.0 and IBM MQ. Same class of bug: a serial receive loop
awaitedProcessinline before pulling the next message, so theSemaphoreSlimwas a dead gate — grabbed and released by the one loop. NowConcurrentConsumers(N)means N genuine competing consumers, each with its own session/connection and loop (AMQPNetLite sessions and the IBM MQ managed client aren't thread-safe, so the concurrency comes from N independent workers, never from sharing a link). For IBM MQ it's also a correctness fix: the syncpoint is connection-scoped andBackout()rolls back the whole connection, so each worker must own its own. IBM MQ topics get clamped to a single subscriber with a warning — N non-durable subscriptions would each get a copy of every message rather than share the load. (redb.Route.Amqp/redb.Route.IbmMq3.2.1.)
All three hotfixes (RabbitMQ 3.2.2, Kafka 3.2.1, Amqp/IbmMq 3.2.1) are now folded into the single 3.3.0 bump. Each ships a live regression test against a real broker: publish a batch, set ConcurrentConsumers(5), assert observed max concurrency is above 1, and assert ConcurrentConsumers(1) stays strictly serial.
Heads-up when you upgrade. If a route of yours set
ConcurrentConsumers(N > 1)and "worked," it was working serially. After 3.3.0 it actually parallelizes: per-queue ordering is no longer preserved on that route, and its handlers need to be thread-safe. Routes left at the default1are untouched — strictly serial, exactly as before.
Bundled in are a new RabbitMQ AutoAck (broker-side auto-ack / at-most-once, the Kafka EnableAutoCommit analogue) and a fix for a double BasicAck when a route-level .Transacted() wraps a non-transacted consumer.
Per-exchange connections: the end of the captive singleton
The second half of that same problem lives in the engine core. IRedbService wraps one non-thread-safe DB connection (think EF DbContext). The DSL steps ProcessWithRedb(...), SetBodyFromRedb(...), SetHeaderFromRedb(...), and BeginRedbTransaction() used to fall back to a single IRedbService captured from the root DI container. Under real concurrency (a Splitter with parallel processing, SEDA, that very ConcurrentConsumers(N), concurrent HTTP requests) two exchanges drove that one connection at once — and the driver threw "A command is already in progress" / "connection is busy".
Now each exchange gets its own DI scope → its own scoped IRedbService → its own pooled connection, cached on the exchange and disposed with it. The unscoped singleton is only used when there's no exchange at all (a single-threaded seed). There's also controller.Redb() for controllers: it resolves the per-request instance instead of the shared captive singleton.
While we were in there we closed the matching DI-scope/connection leaks on the exchange lifecycle — ThreadsProcessor, SedaProducer/VmProducer (leaked the clone on a failed hand-off), WireTapProcessor (leaked the tap clone when a user onPrepare/newBody callback threw), and the scheduled llm:// and exec:// consumers (never disposed their per-tick exchange). Everything now disposes in finally. And we made lazy producer start-up thread-safe: on a cold start under concurrency you could catch a NullReferenceException (a Redis producer whose _db was still null, say); both paths are single-flight now, and a producer only reads as started once ConnectAsync() is fully done.
Short version: 3.3.0 is the release after which you can actually push redb.Route hard and stop chasing flaky connection errors. That's the line between "works in the demo" and "works in prod."
Putting it together: a RAG bot on Telegram, in a handful of routes
So the new pieces don't read like a shopping list, here's how they snap together. Goal: a Telegram bot that answers from your knowledge base (a folder of markdown) instead of making things up. Three routes — ingest, receive-and-answer — and it runs on a concurrent source.
First, ingest. One route: read the files, chunk them, embed them, upsert. The store is redb; no separate vector engine.
// 1. Ingest: a markdown folder → chunks with embeddings in redb
From("file://docs?include=*.md")
.To("knowledge://handbook?chunkChars=1000&overlap=100&embed=true");
Now the bot. Take an update from Telegram, pull the top-5 relevant chunks for the question text, inject them into the system prompt, ask the model. .Knowledge() decides on its own — semantic if an IEmbeddingProvider is wired, keyword otherwise.
// 2. Question from Telegram → RAG → answer back into the same chat
From(Tg.Receive(token))
.Threads(4) // up to 4 conversations in parallel
.Knowledge("handbook", k: 5) // top-5 chunks into the system prompt
.To("llm://claude") // answer grounded on them
.EndThreads()
.To(Tg.Send(token).ChatId(Header(TelegramHeaders.ChatId)));
That's the whole thing. Notice what isn't there: no hand-rolling of vectors, no separate embeddings service, no bespoke glue between Telegram and the LLM, no connection pool to babysit. .Threads(4) gives you parallel conversations on a source that hands out updates one at a time, and each worker gets its own connection when it touches redb.
Want the model to decide when to hit the knowledge base rather than doing it on every turn? Swap .Knowledge() for the knowledge_search tool and pin the collection so it can't wander outside handbook:
// register the tool once; the collection is pinned, so the agent is fenced into it
context.AddRoutes(new KnowledgeSearchTool(new KnowledgeSearchOptions {
Collection = "handbook",
EmbeddingProvider = embeddings // set → semantic; unset → keyword
}));
From(Tg.Receive(token))
.Threads(4)
.To("llm://claude?tools=knowledge_search")
.EndThreads()
.To(Tg.Send(token).ChatId(Header(TelegramHeaders.ChatId)));
Same set of primitives — knowledge://, embed://, .Knowledge(), knowledge_search, telegram://, .Threads() — all of it new or matured in 3.3.0. Each piece is small on its own; together they're a production RAG bot with zero external dependencies beyond the model.
redb (the DB core): what made it in
Three fixes and one guard rail in the core, all in the same concurrency-and-connections theme.
-
A fail-fast guard on the provider connection (
RedBase.Postgres,RedBase.MSSql,RedBase.SQLite+.Pro). If the sameIRedbServiceis entered from two threads at once, the provider now throws a clearInvalidOperationExceptionthat names the cause — instead of the murky driver error ("A command is already in progress", "connection is busy", "another read operation is already in progress"). It's a cheapInterlockedcheck with zero cost on the normal single-threaded path. It backstops the per-exchange connections in redb.Route: if someone does share an instance, they hear about it immediately and in plain English. -
The query parser choked on
array.Contains(x)inWhereRedbon .NET 9 / C# 13. Astring[](or any array).Contains(x)inside a predicate now binds to theReadOnlySpanoverload (MemoryExtensions.Contains) instead ofEnumerable.Contains, which the filter parser was rejecting withNotSupportedException. The parser recognizesMemoryExtensions.Contains, unwraps the array→span conversion, and translates it to the sameINclause. Small, but it bit you out of nowhere on the new compiler. -
ComputeHash()NRE on an object withProps == null. The genericComputeFor<TProps>path dereferenced the object before the null check. Added the guard — the path now returnsnull(→Guid.Empty) consistently instead of throwing. -
A connection-pool leak on transaction/connection dispose (
RedBase.Postgres,RedBase.MSSql,RedBase.SQLite+.Pro). A throw from the driver's transactionDisposeAsync()(possible mid error-storm on an already-broken connection) skipped disposing_connection, so the physical connection never went back to the pool. SinceSaveAsyncruns inside an explicit transaction, every write armed this path — under a burst of failures the leak fed itself and eventually drained the pool (symptom: a healthy pool suddenly climbs pastMaxPoolSizewith connection-timeout errors, cleared only by a restart). Dispose now runs throughtry/finallyon both the connection and the transaction wrapper, so the connection always goes back, and the dispose fault is no longer swallowed — it propagates so it stays visible.
redb.Tsak (the runtime): what made it in
The runtime picks up everything above and adds its own.
-
The new connectors are bundled.
redb.Route.Sqs(sqs://,sns://) andredb.Route.Telegram(telegram://) now ship in the distribution, so modules on Tsak can use them without wiring anything extra. Technically that's a change to the shared-assembly layer — and a small war story from behind the curtain: that's exactly where we caught that the image build script didn't know about the new connectors. We built the image locally, ran the container, watched the logs printLoaded shared assembly redb.Route.Sqsandredb.Route.Telegram, and only then published. -
The dashboard scheduler shows cron jobs even without a
Quartzconfig section. Previously, with noQuartzconfig Tsak registered no shared scheduler, a cron route spun up its own per-context in-memory one, and the management_systemcontext couldn't see it — so the scheduler page was blank even though the route was running. Tsak now always hands out one sharedIScheduler(falling back to an in-memoryRAMJobStorewhen there's no config). No cluster and no database needed for a single node;AdoJobStoreis still how you persist and share jobs across nodes. -
The Users admin API uses a per-request scoped
IRedbService.UsersControllerused to resolve the shared captive singleton, so concurrent admin requests could collide. Now it's a per-request instance viacontroller.Redb(). - Folded in from 3.2.1: the Endpoints page no longer hides anonymous contexts it was still counting in the stats, and the standalone web archive now starts on the documented port 8080 instead of the default 5000.
It ships the usual three ways: NuGet packages, Docker images redb-tsak-{worker,web,stack} (.NET 9; tags :3.3.0-net9, :3.3.0, :latest) on ghcr.io/redbase-app, cosign-signed, and standalone archives redb-tsak-3.3.0-linux-x64.tar.gz / redb-tsak-3.3.0-win-x64.zip with checksums.txt and .bundle signatures on the GitHub release. All on .NET 9.
[SCREENSHOT: the Tsak dashboard on localhost:8080 — the context list with the system echo route]
About Pro being free — once more, with detail
I said it up top, but the "what's the catch" question always comes first, so: there's no catch. Pro stays proprietary (the source is closed), but across the whole 3.x line it's handed out for free and with no bookkeeping — no licenses, keys, sign-up, license server, or node/volume limits.
Concretely, what used to cost money and now doesn't: on the redb core — change tracking, bulk ops, the advanced cache, analytical queries (grouping, window functions); on Tsak — the cluster with its coordinator, the distributed lock, leader election, failover. Exactly the stuff other stacks charge for. Here it's dotnet add package and you're working.
Upgrading from 3.2.x: the short checklist
The release is API-compatible, but there's one behavioral thing worth a look — the parallelism, of course.
-
Walk your routes that set
ConcurrentConsumers(N > 1). They ran serially before (the bug); now they genuinely parallelize. Make sure the handlers on those routes are thread-safe and you aren't leaning on message order. If order matters, keepConcurrentConsumers(1)— that's the default and it hasn't changed. - Check shared state in your handlers. Anything that "happened to work" because the route was effectively single-threaded can now run from N threads.
-
IRedbServicein routes — nothing to do. Per-exchange connections turned on by themselves; if anything, those old "A command is already in progress" errors under concurrency will disappear. If you were manually caching oneIRedbServiceacross exchanges to route around the DSL — stop; the core will now tell you fail-fast. -
One version, across the board. Everything moves to
3.3.0; you don't need to mixredb.Route3.3.0 with a 3.2.0 connector (older 3.2.x connectors are API-compatible — they just don't carry the fresh concurrency fixes). -
Tsak: rebuild or pull a fresh image. If you build your own distribution, the new connectors (
Sqs,Telegram) only land in the shared layer after a rebuild; the published:3.3.0images already have them.
None of this is urgent or build-breaking — it's a "go see where parallelism actually turned on for you" pass.
Getting it
Core and providers, from NuGet:
dotnet add package redb.Core
dotnet add package redb.Postgres # or redb.MSSql / redb.SQLite
dotnet add package redb.Postgres.Pro # Pro — free, no key
The engine and the connectors you need:
dotnet add package redb.Route
dotnet add package redb.Route.Sqs
dotnet add package redb.Route.Telegram
dotnet add package redb.Route.Llm
The runtime, as an image or an archive:
docker pull ghcr.io/redbase-app/redb-tsak-stack:3.3.0
# or a standalone archive from the v3.3.0 GitHub release (linux-x64 / win-x64), cosign-signed
The images are cosign-signed; the public key ships in the release (cosign.pub):
cosign verify --key cosign.pub ghcr.io/redbase-app/redb-tsak-worker:3.3.0
Wrap-up
3.3.0 is about maturity. The new transports (SQS/SNS, Telegram) and the finished RAG loop are nice, but they aren't the point of this release. The point is that we found and fixed a systemic hole in concurrency: ConcurrentConsumers(N) gave you no parallelism on any broker, and a shared captive IRedbService fell apart under load with murky connection errors. That's exactly the class of bug you don't see in a demo and that goes off in prod under load. Now concurrency is honest — on the brokers, on any source via .Threads(N), and with per-exchange connections.
On top of that: the whole stack is on 3.3.0 in lockstep, all on .NET 9, and every Pro package is free with no key. Your own database, your own integration engine, your own runtime with a dashboard and a cluster — one ecosystem, not a menagerie.
If you take it for a spin, drop a comment with what shows up in your setup. Individual connectors and EIP patterns get their own deep-dives in the series.
Source and releases: github.com/redbase-app. About the redb database: redb.ru.
If this was useful — a ⭐ on GitHub helps others find it.

Top comments (0)