DEV Community

rinat kozin
rinat kozin

Posted on

redb 3.3.0: an enterprise .NET stack you actually own — typed store, a homegrown Apache Camel, and a runtime with a dashboard (all free)

redb ecosystem

  • 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:

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();
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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 an IEmbeddingProvider is registered, and upserts into the IKnowledgeStore. Loading documents becomes a route:
  From("file://docs?include=*.md").To("knowledge://handbook");
Enter fullscreen mode Exit fullscreen mode

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 of llm://: To("embed://openai") turns the message body (a text, or a batch of texts with order preserved) into a vector on Out.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");
Enter fullscreen mode Exit fullscreen mode

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 .AsLlmTool route 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();
Enter fullscreen mode Exit fullscreen mode

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 in RabbitMQ.Client 7.2.1 that default is 1, not null. 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. Now ConcurrentConsumers is the single knob for consumer parallelism: the channel always opens with an explicit dispatch concurrency. (redb.Route.RabbitMQ 3.2.2.)
  • Kafka. Plus a new EnableAutoCommit option (default true) to bring offset-settle in line with RabbitMQ's post-process ack — and a fix for the transacted producer that was throwing Local: Erroneous state on the deferred send. (redb.Route.Kafka 3.2.1.)
  • AMQP 1.0 and IBM MQ. Same class of bug: a serial receive loop awaited Process inline before pulling the next message, so the SemaphoreSlim was a dead gate — grabbed and released by the one loop. Now ConcurrentConsumers(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 and Backout() 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.IbmMq 3.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 default 1 are 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");
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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 same IRedbService is entered from two threads at once, the provider now throws a clear InvalidOperationException that 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 cheap Interlocked check 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) in WhereRedb on .NET 9 / C# 13. A string[] (or any array) .Contains(x) inside a predicate now binds to the ReadOnlySpan overload (MemoryExtensions.Contains) instead of Enumerable.Contains, which the filter parser was rejecting with NotSupportedException. The parser recognizes MemoryExtensions.Contains, unwraps the array→span conversion, and translates it to the same IN clause. Small, but it bit you out of nowhere on the new compiler.
  • ComputeHash() NRE on an object with Props == null. The generic ComputeFor<TProps> path dereferenced the object before the null check. Added the guard — the path now returns null (→ 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 transaction DisposeAsync() (possible mid error-storm on an already-broken connection) skipped disposing _connection, so the physical connection never went back to the pool. Since SaveAsync runs 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 past MaxPoolSize with connection-timeout errors, cleared only by a restart). Dispose now runs through try/finally on 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://) and redb.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 print Loaded shared assembly redb.Route.Sqs and redb.Route.Telegram, and only then published.
  • The dashboard scheduler shows cron jobs even without a Quartz config section. Previously, with no Quartz config Tsak registered no shared scheduler, a cron route spun up its own per-context in-memory one, and the management _system context couldn't see it — so the scheduler page was blank even though the route was running. Tsak now always hands out one shared IScheduler (falling back to an in-memory RAMJobStore when there's no config). No cluster and no database needed for a single node; AdoJobStore is still how you persist and share jobs across nodes.
  • The Users admin API uses a per-request scoped IRedbService. UsersController used to resolve the shared captive singleton, so concurrent admin requests could collide. Now it's a per-request instance via controller.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, keep ConcurrentConsumers(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.
  • IRedbService in 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 one IRedbService across 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 mix redb.Route 3.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.0 images 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)