DEV Community

rinat kozin
rinat kozin

Posted on

Two routes in an evening: from a debug worker to an enterprise runtime with redb.Route + Tsak

redb.tsak

  • Series: redb ecosystem

Integration code has a funny asymmetry to it.

Writing a couple of routes — "take an HTTP request, stash it in a database, hand something back" — is a half-hour job. But getting that same thing to run in production, come up on its own, show you metrics, let you stop and start individual pieces by hand, and redeploy without a rebuild? That's usually a completely different stack and a completely different afternoon.

This post is about how, with redb.Route + redb.Tsak, it's literally the same code. We'll:

  1. write two tiny routes (POST to save, GET to fetch by a query param) on redb.Route;
  2. build our own debug worker — a plain console app you can run under the debugger with breakpoints;
  3. and then, without changing a single line of the routes, pack it into a module and drop it into Tsak — where the exact same project picks up a dashboard, hot-reload, live route management, and enterprise deployment (with Docker and without).

All the code is in the repo (redb.Route/demos/EchoWorkerDemo), and I'm pasting it here in full so you can follow along.

Part of the redb / redb.Route series — recent posts first:

Sources: github.com/redbase-app. About the database itself: redb.ru.

This one is the gentle intro to workers and deployment. Clustering — the coordinator, leader election, failover — gets its own post.


What we're building

A tiny notes service. Two endpoints on a single HTTP port:

  • POST /api/notes with a body like {"tag":"work","text":"hello"} — save a note into a redb database (on SQLite);
  • GET /api/notes?tag=work — return notes with that tag; the lookup runs server-side via Where(...).ToListAsync(), with the parameter pulled straight from the query string.

Nothing fancy. The point of the post isn't the business logic — it's the lifecycle: how one and the same set of routes first lives in a debug console, and then in an enterprise runtime.

the payoff shot — console with curl , the Tsak dashboard

Tsak dashboard

Tsak dashboard


Project layout: two projects, one entry point

Here's the shape of it:

EchoWorkerDemo/
├─ EchoModule/            <- project 1: the module (class library -> EchoModule.tpkg)
│  ├─ InitRoute.cs        <- main(IRouteContext): two routes + the Note class
│  ├─ manifest.json       <- { Name, Version, EntryPoints: ["EchoModule.dll"] }
│  ├─ EchoModule.config.json  <- context name (ContextName) + AutoStart
│  └─ EchoModule.csproj   <- references to the connectors + the PackTpkg target
└─ EchoWorker/            <- project 2: the debug host (exe)
   ├─ Program.cs          <- redb on SQLite + a call to InitRoute.main + Start
   └─ EchoWorker.csproj
Enter fullscreen mode Exit fullscreen mode

The whole thing hinges on one idea: there is exactly one entry point — InitRoute.main(IRouteContext). Both our debug worker and the real Tsak runtime call it. The route code lives in exactly one place — EchoModule. The debug EchoWorker is just the plumbing that stands up a database and calls that same main.

Why two projects and not one? You can do one — an exe still produces a DLL, and Tsak finds InitRoute.main by reflection in any assembly. But splitting the two roles makes them obvious: EchoModule is what you ship (it packs into a .tpkg), and EchoWorker is what you debug with. The module carries zero host code — which is exactly right, because in production Tsak hands it the database.


Project 1: the module with two routes

Here it is in full — EchoModule/InitRoute.cs:

using System.Text.Json;

using redb.Core;                          // IRedbService, Query, SaveAsync, SyncSchemeAsync
using redb.Core.Attributes;               // RedbScheme
using redb.Core.Models.Entities;          // RedbObject<T>

using redb.Route.Abstractions;            // IRouteContext, IExchange
using redb.Route.Core;                    // RouteContext
using redb.Route.Http;                    // HttpComponent, SharedHttpServerManager
using redb.Route.RedbCore.Extensions;     // ProcessWithRedb, GetRedbService

namespace EchoModule;

/// <summary>
/// Tsak module entry point.
/// The worker discovers it by convention — a public static class named InitRoute
/// with a public static main(IRouteContext) — and calls it once when the module
/// loads. The debug host (EchoWorker/Program.cs) calls the very same method, so the
/// route code below lives in exactly one place.
///
/// Two minimal endpoints on the shared HTTP server (port 5099), backed by redb/SQLite:
///   POST /api/notes   body {"tag":"work","text":"hello"}   -> save one note
///   GET  /api/notes?tag=work                               -> list notes with that tag
/// </summary>
public static class InitRoute
{
    private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };

    public static IRouteContext main(IRouteContext context)
    {
        // redb schema for Note. Idempotent — safe to call every load. The worker
        // (or the debug host) has already brought redb + SQLite up by now.
        context.GetRedbService().SyncSchemeAsync<Note>().GetAwaiter().GetResult();

        // One shared HTTP server; both routes below bind to it.
        context.AddComponent(new HttpComponent { ServerManager = new SharedHttpServerManager() });

        ((RouteContext)context).AddRoutes(r =>
        {
            // --- POST /api/notes — save one note ---
            r.From("http:0.0.0.0:5099/api/notes?inOut=true&methods=POST")
                .RouteId("notes-post")
                .ConvertBody<string>()                       // HTTP body -> string (JSON)
                .ProcessWithRedb(async (db, ex, ct) =>
                {
                    var note = JsonSerializer.Deserialize<Note>(ex.In.Body?.ToString() ?? "{}", Json) ?? new Note();
                    var obj = new RedbObject<Note> { name = $"note:{note.Tag}", Props = note };
                    await db.SaveAsync(obj);                 // one insert into redb (SQLite)
                    Reply(ex, new { saved = true, id = obj.id });
                }).Log("Save ${body}");

            // --- GET /api/notes?tag=work — list by tag ---
            r.From("http:0.0.0.0:5099/api/notes?inOut=true&methods=GET")
                .RouteId("notes-get")
                .ProcessWithRedb(async (db, ex, ct) =>
                {
                    // ?tag=... arrives as the header redbHttp.QueryParam.tag
                    var tag = ex.In.Headers.TryGetValue("redbHttp.QueryParam.tag", out var t)
                        ? t?.ToString() ?? ""
                        : "";

                    // Server-side filter: the GET parameter goes straight into Where(...).
                    var found = await db.Query<Note>()
                        .Where(n => n.Tag == tag)
                        .ToListAsync();

                    Reply(ex, found.Select(o => new { o.Props.Tag, o.Props.Text }));
                }).Log("Load ${header.redbHttp.QueryParam.tag}");
        });

        return context;
    }

    // inOut=true -> whatever the body is at the end becomes the HTTP response.
    private static void Reply(IExchange ex, object body)
    {
        ex.In.ContentType = "application/json";
        ex.In.Body = JsonSerializer.Serialize(body);
    }
}

/// <summary>Persisted note. [RedbScheme] marks the class as a redb schema.</summary>
[RedbScheme]
public sealed class Note
{
    public string Tag { get; set; } = "";
    public string Text { get; set; } = "";
}
Enter fullscreen mode Exit fullscreen mode

Let's walk through the parts that matter — nearly every line is pulling its weight here.

The main entry point

public static IRouteContext main(IRouteContext context)
Enter fullscreen mode Exit fullscreen mode

This is the Tsak module contract. No attributes, no interface to implement — just a naming convention: a public static class InitRoute, a public static method main taking an IRouteContext. When Tsak loads your assembly, it scans it by reflection, finds this method, and calls it with a context. Whatever you hang on that context — components, routes, listeners — becomes part of the runtime.

The neat bit: this works in the debug worker too. There, we create the RouteContext and we call InitRoute.main(ctx). Same method, two hosts.

The schema

context.GetRedbService().SyncSchemeAsync<Note>().GetAwaiter().GetResult();
Enter fullscreen mode Exit fullscreen mode

Note is tagged [RedbScheme], which marks it as a persistable redb class. SyncSchemeAsync<Note>() registers its schema (idempotent — call it on every load, no harm done). GetRedbService() pulls IRedbService off the context: in Tsak the database is already up before your module runs, and in the debug worker we'll stand it up ourselves before main. Either way, redb is there by the time we ask.

Notice what the module doesn't do: it never configures the database. It doesn't know or care whether that's SQLite, Postgres, or MSSQL — it just asks the context for an IRedbService. The provider is the host's problem.

One HTTP server, two routes

context.AddComponent(new HttpComponent { ServerManager = new SharedHttpServerManager() });
Enter fullscreen mode Exit fullscreen mode

SharedHttpServerManager is a shared HTTP server — several routes can hang off one port. Both of ours sit on 5099.

r.From("http:0.0.0.0:5099/api/notes?inOut=true&methods=POST")
r.From("http:0.0.0.0:5099/api/notes?inOut=true&methods=GET")
Enter fullscreen mode Exit fullscreen mode

Both listen on the same path /api/notes, but they split on method via ?methods=POST / ?methods=GET. The server matches an incoming request on the pair "path + method": a POST goes to the first route, a GET to the second, and a PUT /api/notes gets an honest 405 Method Not Allowed (path matched, method didn't). inOut=true means request/response: whatever ends up in the exchange body at the end of the route becomes the HTTP response.

POST: save

.ConvertBody<string>()
.ProcessWithRedb(async (db, ex, ct) =>
{
    var note = JsonSerializer.Deserialize<Note>(ex.In.Body?.ToString() ?? "{}", Json) ?? new Note();
    var obj = new RedbObject<Note> { name = $"note:{note.Tag}", Props = note };
    await db.SaveAsync(obj);
    Reply(ex, new { saved = true, id = obj.id });
}).Log("Save ${body}");
Enter fullscreen mode Exit fullscreen mode

ConvertBody<string>() turns the raw HTTP body (bytes) into a string. ProcessWithRedb is a processing step into which redb.Route injects the IRedbService (db) for you: under the hood it grabs the per-exchange DI scope if there is one, otherwise the context singleton. The rest is plain C#: deserialize the JSON into a Note, wrap it in a RedbObject<Note>, save it with a single SaveAsync, hand back { saved, id }.

The trailing .Log("Save ${body}") is a log step with interpolation: ${body} substitutes the current exchange body. redb.Route's ${...} can pull body, headers (${header.X}), and more — handy for tracing right inside the DSL.

GET: fetch by param

var tag = ex.In.Headers.TryGetValue("redbHttp.QueryParam.tag", out var t)
    ? t?.ToString() ?? ""
    : "";

var found = await db.Query<Note>()
    .Where(n => n.Tag == tag)
    .ToListAsync();

Reply(ex, found.Select(o => new { o.Props.Tag, o.Props.Text }));
Enter fullscreen mode Exit fullscreen mode

redb.Route lays out HTTP query params as headers prefixed with redbHttp.QueryParam., so ?tag=work shows up as the header redbHttp.QueryParam.tag. Grab it, drop it straight into a server-side query:

db.Query<Note>().Where(n => n.Tag == tag).ToListAsync()
Enter fullscreen mode Exit fullscreen mode

This is not "load everything into memory, then filter" — it's a query on the database side: the lambda n => n.Tag == tag compiles to a condition on the Note.Tag field. The result is a collection of RedbObject<Note>, so we reach the fields through .Props.

And that's the whole module. Two routes, a data class, zero infrastructure. Now let's run and debug it.


Project 2: our own debug worker

To run the routes under a debugger without spinning up any Tsak at all, we'll build a tiny console app. It reproduces exactly what the Tsak worker does when it loads a module, just in the smallest possible way. Here's EchoWorker/Program.cs in full:

// ============================================================================
//  EchoWorker — a debug host for the EchoModule Tsak module.
//
//  It reproduces, in the smallest possible way, what the Tsak worker does:
//    1) stand redb up on SQLite (Free tier — the worker's default),
//    2) create the redb system tables once,
//    3) hand a RouteContext to EchoModule.InitRoute.main — the SAME entry point
//       the worker calls, so no route code is duplicated here.
//
//  Run it, then (PowerShell — JSON in single quotes; cmd.exe needs \" escaping instead):
//    POST: curl.exe -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d '{"tag":"work","text":"hello"}'
//    GET:  curl.exe "http://localhost:5099/api/notes?tag=work"
// ============================================================================

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

using redb.Core;                          // IRedbService
using redb.Core.Extensions;               // AddRedb
using redb.Core.Models.Configuration;     // PropsSaveStrategy
using redb.SQLite.Pro.Extensions;         // UseSqlite (tier-agnostic: AddRedb -> Free)
using redb.SQLite.Data;                   // SqliteDataSource.NativeExtensionPath

using redb.Route.Core;                    // RouteContext

namespace EchoWorker;

public static class Program
{
    public static async Task Main(string[] args)
    {
        // Free SQLite needs the native redb extension. The packaged Tsak worker ships
        // it; running from source we point at the one built under redb.SQLite/native/build.
        SqliteDataSource.NativeExtensionPath ??= ResolveNativeExtension();

        // DI: console logging + redb on SQLite (single-file DB next to the exe).
        var services = new ServiceCollection();

        services.AddLogging(b => b
            .AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; })
            .SetMinimumLevel(LogLevel.Information));

        services.AddRedb(o => o
            .UseSqlite("Data Source=echo_demo.db")
            .Configure(c => c.PropsSaveStrategy = PropsSaveStrategy.DeleteInsert));

        var sp = services.BuildServiceProvider();

        // Create the redb system tables once (the worker does this on boot).
        // ensureCreated: true builds the base tables on a fresh SQLite file.
        await sp.GetRequiredService<IRedbService>().InitializeAsync(ensureCreated: true);

        // Build a route context over that provider and call the module entry point.
        var ctx = new RouteContext(sp, contextId: "echo-worker");
        ctx.AddService(typeof(ILoggerFactory), sp.GetRequiredService<ILoggerFactory>());
        EchoModule.InitRoute.main(ctx);       // <- the exact method the Tsak worker calls

        await ctx.Start();

        Console.WriteLine();
        Console.WriteLine("EchoWorker running: http://localhost:5099/api/notes");
        Console.WriteLine("  POST  {\"tag\":\"work\",\"text\":\"hello\"}   -> save");
        Console.WriteLine("  GET   ?tag=work                          -> list by tag");
        Console.WriteLine("Ctrl+C to exit.");
        Console.WriteLine();

        var stop = new ManualResetEventSlim();
        Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Set(); };
        stop.Wait();

        await ctx.DisposeAsync();
    }

    // Walk up from the app dir to the repo's built Free SQLite native extension.
    // Returns null when running from a packaged worker (it resolves the extension itself).
    private static string? ResolveNativeExtension()
    {
        var suffix = OperatingSystem.IsWindows() ? ".dll"
                   : OperatingSystem.IsMacOS()   ? ".dylib"
                   : ".so";
        for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
        {
            var candidate = Path.Combine(dir.FullName, "redb.SQLite", "native", "build", "redb" + suffix);
            if (File.Exists(candidate))
                return candidate;
        }
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Step by step:

1. The SQLite native extension. On its Free tier, redb-on-SQLite uses a native loadable extension (redb.dll / .so / .dylib) — part of the query machinery lives in there. The packaged Tsak worker ships that extension in the box. When you run from source, you have to point at the built binary — ResolveNativeExtension() just walks up the directory tree looking for redb.SQLite/native/build/redb.dll. One honest detail worth not hiding: without that extension, the Free SQLite provider won't start.

2. DI and the database. A regular ServiceCollection: console logging plus AddRedb(...).UseSqlite("Data Source=echo_demo.db"). UseSqlite is tier-agnostic: AddRedb gives you Free, AddRedbPro gives you Pro — you flip tiers without touching the usings. And this is the exact same tier (Free/SQLite) the Tsak worker runs by default, so your debug environment matches production.

3. System tables. InitializeAsync(ensureCreated: true) creates redb's base tables on a fresh file. In Tsak the worker does this on boot; in the debug host, we do it.

4. The context and the module call. We build a RouteContext over our provider and call EchoModule.InitRoute.main(ctx). That's the exact entry point Tsak will call. No route code is duplicated — it all lives in the module.

5. Start and wait. ctx.Start() brings up the HTTP server on 5099; then we sit until Ctrl+C.

The project files

EchoModule/EchoModule.csproj — a library plus a .tpkg packaging target:

<Project Sdk="Microsoft.NET.Sdk">

  <!-- Project 1 of 2: the Tsak MODULE.
       A class library. Its DLL is what gets packed into EchoModule.tpkg and
       hot-loaded by the Tsak worker. It contains ONLY route code — no host,
       no DB provider: the worker supplies redb + SQLite at runtime. -->
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>EchoModule</RootNamespace>
    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
  </PropertyGroup>

  <!-- The connectors the worker already ships in its shared Libs. We compile
       against them but the .tpkg carries only EchoModule.dll (see PackTpkg). -->
  <ItemGroup>
    <ProjectReference Include="..\..\..\src\redb.Route\redb.Route.csproj" />
    <ProjectReference Include="..\..\..\src\redb.Route.Core\redb.Route.Core.csproj" />
    <ProjectReference Include="..\..\..\src\redb.Route.Http\redb.Route.Http.csproj" />
    <ProjectReference Include="..\..\..\..\redb.Core\redb.Core.csproj" />
  </ItemGroup>

  <!-- After build: zip manifest.json + the module DLL into output/EchoModule.tpkg. -->
  <PropertyGroup>
    <TsakModuleName>EchoModule</TsakModuleName>
  </PropertyGroup>
  <Target Name="PackTpkg" AfterTargets="Build">
    <PropertyGroup>
      <_TpkgStaging>$(IntermediateOutputPath)tpkg</_TpkgStaging>
      <_TpkgFile>$(MSBuildThisFileDirectory)output\$(TsakModuleName).tpkg</_TpkgFile>
    </PropertyGroup>
    <RemoveDir Directories="$(_TpkgStaging)" />
    <MakeDir Directories="$(_TpkgStaging)" />
    <MakeDir Directories="$(MSBuildThisFileDirectory)output" />
    <Copy SourceFiles="$(MSBuildThisFileDirectory)manifest.json" DestinationFolder="$(_TpkgStaging)" />
    <Copy SourceFiles="$(TargetPath)" DestinationFolder="$(_TpkgStaging)" />
    <!-- {ModuleName}.config.json gives the module a named context (ContextName). -->
    <Copy SourceFiles="$(MSBuildThisFileDirectory)$(TsakModuleName).config.json"
          DestinationFolder="$(_TpkgStaging)"
          Condition="Exists('$(MSBuildThisFileDirectory)$(TsakModuleName).config.json')" />
    <ZipDirectory SourceDirectory="$(_TpkgStaging)" DestinationFile="$(_TpkgFile)" Overwrite="true" />
    <Message Importance="high" Text="Packed $(TsakModuleName) -> $(_TpkgFile)" />
  </Target>

</Project>
Enter fullscreen mode Exit fullscreen mode

EchoWorker/EchoWorker.csproj — a console exe that references the module:

<Project Sdk="Microsoft.NET.Sdk">

  <!-- Project 2 of 2: the DEBUG HOST.
       A tiny console app. It stands redb up on SQLite (the same Free tier the Tsak
       worker uses by default), then calls EchoModule.InitRoute.main(ctx) — the exact
       method the worker calls. Run/F5 this to debug the route without Tsak. -->
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>EchoWorker</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\EchoModule\EchoModule.csproj" />
    <!-- UseSqlite is tier-agnostic: AddRedb -> Free (what Tsak uses by default). -->
    <ProjectReference Include="..\..\..\..\redb.SQLite.Pro\redb.SQLite.Pro.csproj" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.3" />
  </ItemGroup>

</Project>
Enter fullscreen mode Exit fullscreen mode

There's an important detail in the dependencies. The debug host references the SQLite provider (redb.SQLite.Pro) — because it stands up the database. The module itself does not reference any DB provider: in Tsak, the worker provides the database. So the .tpkg only ships EchoModule.dll (see the PackTpkg target — it puts exactly the target DLL, the manifest, and the config into the archive, not the dependencies). The redb.Route.* and redb.Core connectors are already in the worker, which is why the package stays tiny.

Run it and poke it

dotnet run --project EchoWorker
Enter fullscreen mode Exit fullscreen mode

Then, in another window, hit the endpoints. One Windows wrinkle worth calling out: escaping JSON for curl differs between cmd and PowerShell. In PowerShell the \" form does not work — you'll get '\' is an invalid start of a property name. So here's both.

cmd.exe (JSON in double quotes, inner ones escaped with \"):

curl -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d "{\"tag\":\"work\",\"text\":\"hello\"}"
curl "http://localhost:5099/api/notes?tag=work"
Enter fullscreen mode Exit fullscreen mode

PowerShell (wrap the JSON in single quotes — no escaping of the inner double quotes; and it must be curl.exe, since curl is an alias for Invoke-WebRequest):

curl.exe -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d '{"tag":"work","text":"hello"}'
curl.exe -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d '{"tag":"home","text":"other"}'
curl.exe "http://localhost:5099/api/notes?tag=work"
Enter fullscreen mode Exit fullscreen mode

PowerShell, no curl (native):

Invoke-RestMethod -Method Post -Uri http://localhost:5099/api/notes -ContentType 'application/json' -Body '{"tag":"work","text":"hello"}'
Invoke-RestMethod "http://localhost:5099/api/notes?tag=work"
Enter fullscreen mode Exit fullscreen mode

The responses:

{"saved":true,"id":1000019}
{"saved":true,"id":1000022}
[{"Tag":"work","Text":"hello"}]
Enter fullscreen mode Exit fullscreen mode

A GET with ?tag=home returns the other note, and PUT /api/notes gives you a 405. It all works, and it's all under the debugger: drop a breakpoint inside ProcessWithRedb, fire a curl, and you're sitting right in the handler with a live db and ex.

That's the debug zone: a plain console app, F5, breakpoints, a live SQLite file next to the exe, raw logs to stdout. This is where you write and debug your routes. Iteration loop: "tweak → run → poke with curl."

the EchoWorker console — Save/Load logs — next to a curl session with the POST/GET responses


Here's the trick: the same code, now enterprise

Now for the good part. We don't touch the routes at all. We take the same EchoModule.dll, add two tiny descriptor files, pack it into a .tpkg, and hand it to Tsak. One step — and our "console app under F5" gains a dashboard, metrics, hot-reload, and live management. New code written: zero.

What a .tpkg is

A .tpkg is just a ZIP with three things:

  • manifest.json — the module's passport;
  • EchoModule.dll — the assembly with InitRoute.main;
  • EchoModule.config.json — the module's config (first and foremost, its context name).

manifest.json:

{
  "Name": "EchoModule",
  "Version": "1.0.0",
  "EntryPoints": ["EchoModule.dll"],
  "Dependencies": []
}
Enter fullscreen mode Exit fullscreen mode

EchoModule.config.json:

{
  "ContextName": "echo",
  "AutoStart": true
}
Enter fullscreen mode Exit fullscreen mode

A word on ContextName, because it's an easy trap. If you don't set it, Tsak loads the module into an anonymous context with an auto-generated name (something like EchoModule_dyn_<date>_<guid>). The module runs fine, the routes work — but the dashboard's Endpoints page hides anonymous contexts by default (while still counting them in the totals, so the numbers won't match what you see in the list). The takeaway is simple: name your context via EchoModule.config.json, and it shows up neatly as echo. Small thing, big peace of mind.

Pack it

The PackTpkg target in the .csproj does everything at build time:

dotnet build EchoModule -c Debug
Enter fullscreen mode Exit fullscreen mode

Out comes EchoModule/output/EchoModule.tpkg. Peek inside:

Archive:  output/EchoModule.tpkg
  Length      Name
---------  ----
       49  EchoModule.config.json
    13312  EchoModule.dll
      108  manifest.json
Enter fullscreen mode Exit fullscreen mode

Three files, ~7 KB. That's your deployment artifact.


How Tsak finds modules: folders and hot-reload

This is the part the whole thing was built for. Tsak doesn't "watch" the folder with filesystem events — internally there's a background HotReloadService that polls the directories listed in the Tsak:Modules:AssemblyPaths config on a timer.

The flow:

  • The worker config has a list of paths, Tsak:Modules:AssemblyPaths. The module folder is usually called modules.
  • Every Tsak:HotReload:ScanIntervalSeconds (default 10 seconds) the service walks each path and grabs every *.tpkg (and bare *.dll too).
  • "New / changed / removed" is decided by file modification time:
    • unchanged mtime → skip;
    • new file → unzip it, load the assembly into an isolated load context (its own AssemblyLoadContext per package), find InitRoute.main by reflection, bring the context up;
    • changed file → reload the package atomically (drop the old version, load the new one, preserving state such as whether the context was running);
    • file gone from disk → unload every module in that package.
  • Shared connectors (redb.Route.*, redb.Core) resolve from the worker's shared libraries (Libs/shared), which is why they're not inside the .tpkg.

The practical upshot:

  • Drop or update EchoModule.tpkg in modules/ and it gets picked up within ~10 seconds, no restart.
  • The trigger is the file's mtime. A plain copy bumps the mtime, so "re-copied" = "reloaded."
  • Each .tpkg lives in its own load context — module versions are isolated from each other.

In other words, deploying a new module (or a new version) is copying one file into a folder. Tsak takes it from there.


The dashboard: watch it, and run it

Stand Tsak up (two ways below), drop EchoModule.tpkg into the modules folder, wait a few seconds, and open the web dashboard. Our echo context is right there, with its two endpoints.

Tsak dashboard — the Endpoints page, the echo context with routes notes-post and notes-get

And here's the second big difference from the debug console. In the console we only had text logs. In Tsak it's the same information, but:

  • you see the echo context and its endpoints — type (HTTP), role (Consumer), in/out counters, errors, throughput, uptime, health;
  • you see the _SYSTEM context — that's the worker's own management API;
  • you can drill into each endpoint for details (messages, bytes, average processing time, last errors).

Tsak — the notes-get endpoint card with metrics and details

But the real point is that you can operate it, right from the browser, without touching configs or redeploying. On the node detail page for our echo context you get:

  • the context as a whole — Start / Stop / Restart, plus Reset route states (clear the saved route states);
  • individual routes — stop/start a specific notes-post or notes-get without touching its neighbor;
  • schedules (if the module has Quartz jobs) — pause and resume jobs on the scheduler tab.

And here's the important bit: state sticks. If you manually stop a route, then hot-reload will not bring it back up when the package updates — Tsak respects the operator's decision. A route you stopped in production won't suddenly come back to life just because you rolled out a new .tpkg version. Management is both "hot" (takes effect immediately) and "sticky" (survives a module reload).

The management actions live behind the admin role — so this is genuinely an operator's console, not just a metrics wall.

Tsak — the node/context detail page with Start/Stop/Restart buttons on the notes-post / notes-get routes

Feel the contrast: in the custom worker you debug (F5, breakpoints, a bare console); in Tsak you operate (observe, manage, deploy). Same code, two environments, two very different jobs.

two panels side by side — EchoWorker console on the left, the Tsak dashboard with the echo context on the right


Ports: defaults, and how to set them

Since we're running this across different environments, let's put the ports on the table — it's easy to get lost in what lives where.

  • The module (our routes) — 5099. That's the port we wrote into From("http:0.0.0.0:5099/..."). The module's own HTTP server. Want a different one? Change it in the route.
  • The worker (Tsak management API) — 9090. A separate server: health, metrics, context management, the cluster API. This is what the dashboard talks to. The port comes from the worker config.
  • The web dashboard — pay attention here. If neither ASPNETCORE_URLS nor a Kestrel binding is set in the web config, ASP.NET Core falls back to its default — http://localhost:5000. The Docker images and the distribution's startup scripts set ASPNETCORE_URLS=http://localhost:8080 (or http://+:8080 in a container), which is why the dashboard is on 8080 under Docker and in the docs. Run the web "bare" without that variable and don't be surprised to find the UI on 5000.

To set it explicitly:

# before launching the web
$env:ASPNETCORE_URLS = "http://localhost:8080"
Enter fullscreen mode Exit fullscreen mode

or in the web's appsettings.json:

"Kestrel": { "Endpoints": { "Http": { "Url": "http://localhost:8080" } } }
Enter fullscreen mode Exit fullscreen mode

Cheat sheet: 9090 — worker API, 5099 — our endpoints, 8080 — dashboard (when ASPNETCORE_URLS is set; otherwise the framework default 5000).


Enterprise deploy #1: Docker (the whole stack in one container)

The simplest way to bring it all up at once is the redb-tsak-stack image: worker + dashboard in a single container. The images live on the org's packages page — there are several: redb-tsak-worker, redb-tsak-web, and the combined redb-tsak-stack (plus per-TFM variants). For a quick start, stack is all we need. Here's the docker-compose.yml in full:

# Minimal redb.Tsak stack — Worker (API) + Blazor dashboard in one container.
# Drop a module as a .tpkg into ./modules and the worker hot-loads it.
#
# Start:
#   docker compose up -d
#
# Dashboard:   http://localhost:8080   (login admin / admin)
# Tsak API:    http://localhost:9090/api/health/live
# EchoModule endpoints:  http://localhost:5099/api/notes
#   POST (PowerShell): curl.exe -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d '{"tag":"work","text":"hello"}'
#   POST (cmd.exe):    curl -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d "{\"tag\":\"work\",\"text\":\"hello\"}"
#   GET:  curl.exe "http://localhost:5099/api/notes?tag=work"

services:
  tsak:
    image: ghcr.io/redbase-app/redb-tsak-stack:latest
    container_name: tsak
    restart: unless-stopped
    ports:
      - "8080:8080"   # Blazor dashboard
      - "9090:9090"   # Tsak management REST API (health, cluster, contexts...)
      - "5099:5099"   # the EchoModule's own HTTP server (/api/notes)
    environment:
      # redb store on the bundled SQLite — no external dependencies
      Tsak__Storage__Type: Redb
      # HMAC secret for API-key auth (any >=16 chars; change for real use)
      Tsak__Auth__Secret: "demo-secret-change-me-please-0123456789"
    volumes:
      # your .tpkg modules — hot-loaded by the worker
      - ./modules:/app/worker/modules
Enter fullscreen mode Exit fullscreen mode

What matters here:

  • The image ghcr.io/redbase-app/redb-tsak-stack:latest — worker and dashboard in one container (under a supervisor). Storage defaults to the bundled SQLite, no external dependencies.
  • The ports — the same three: 8080 (dashboard), 9090 (worker API), 5099 (our endpoints).
  • The volume ./modules:/app/worker/modules — this is where the .tpkg goes. Inside the stack image the module folder is /app/worker/modules, which is exactly the path hot-reload watches.
  • Tsak__Auth__Secret — the HMAC secret for signing API keys; for a local run any value of 16+ chars will do.

Layout on the host:

tsak/
├─ docker-compose.yml
└─ modules/
   └─ EchoModule.tpkg
Enter fullscreen mode Exit fullscreen mode

Bring it up:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Dashboard at http://localhost:8080 (admin / admin). Test the endpoints (PowerShell):

curl.exe -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d '{"tag":"work","text":"hello"}'
curl.exe "http://localhost:5099/api/notes?tag=work"
Enter fullscreen mode Exit fullscreen mode

That's it. The same module we debugged in a console now runs in a container with a dashboard. To update the module: rebuild the .tpkg, re-copy it into modules/, and it gets picked up in ~10 seconds.

docker conteiner redb.tsak

docker conteiner redb.tsak


Enterprise deploy #2: no container (standalone archive)

Don't want Docker? Fine. The releases page has self-contained archives per platform — for example, the v3.2.0 tag ships archives for win-x64, linux-x64, and so on (each is cosign-signed, with a .bundle and an SBOM alongside for integrity checks).

Inside an extracted archive (redb-tsak-<version>-<platform>), the layout is:

redb-tsak-3.2.0-win-x64/
├─ worker/     <- the runtime: redb.Tsak.Worker.exe (+ Libs/shared with connectors, + modules/)
├─ web/        <- the dashboard: redb.Tsak.Web.exe
├─ cli/        <- the tsak command-line tool
├─ scripts/    <- start-worker / start-web / start-stack (.bat / .ps1 / .sh)
├─ README.txt
├─ LICENSE, NOTICE
Enter fullscreen mode Exit fullscreen mode

Launch from scripts/:

  • start-stack — bring up worker and dashboard together;
  • start-worker — just the runtime;
  • start-web — just the dashboard.

On Windows, for instance:

.\scripts\start-stack.ps1
Enter fullscreen mode Exit fullscreen mode

Same ports: worker on 9090, dashboard on 8080 (the scripts set ASPNETCORE_URLS; run web completely bare, outside the script, and remember the 5000 default from the ports section).

Where the module goes. In the standalone layout, modules live next to the worker — in its modules/ folder (the path comes from Tsak:Modules:AssemblyPaths). Drop our EchoModule.tpkg there:

worker/
└─ modules/
   └─ EchoModule.tpkg
Enter fullscreen mode Exit fullscreen mode

Leave Libs/shared alone — that's the distribution's shared connectors, the layer your lightweight package loads on top of.

Licensing: with no key, the runtime starts in OSS mode. Pro features (clustering included) turn on via the Tsak__Redb__License__0 environment variable with your JWT, or by editing worker/appsettings.json before launch.

So — two deployment paths, container or archive, but one model: the .tpkg artifact goes into the modules folder, and hot-reload takes it from there.


Two zones: build and operate

Let's put the whole picture together.

  • The build/debug zone — the EchoWorker project. A console app, F5, breakpoints, a local SQLite file next to the exe, raw logs to the console. This is where you write and debug routes. Loop: "tweak → run → poke with curl."
  • The operations zone — Tsak (Docker or archive). The same module, but with a dashboard, metrics, hot-reload, and live management. This is where you deploy and operate. Loop: "rebuild the .tpkg → copy into modules/ → picked up."

And the key thing: not a single line of the routes changes between those zones. InitRoute.main is the same. The only difference is the plumbing — in debug you write it (a dozen lines of Program.cs), in production Tsak provides it.


What's next: clustering (a teaser)

Everything above is single-node. But Tsak clusters too: multiple workers, one shared dashboard, modules and routes distributed across nodes, leader election, automatic takeover when a node dies — and the whole cluster topology lives in the redb database itself, with no separate membership infrastructure (no ZooKeeper/etcd/Consul). There's also active-passive for routes: the same route consumer runs on exactly one node, and if that node falls over, another one picks it up.

That's a topic for the next post, though. This one was the intro: how to knock out two routes in an evening, build yourself a debug worker, and then take the exact same code into an enterprise runtime. The coordinator, the leader, and failover — we'll get into those properly next time.

redb.tsak dashboard

redb.tsak monitoring


Wrap-up

We walked the whole path on one tiny example:

  1. wrote two routes on redb.Route (POST to save into redb/SQLite, GET to fetch with a server-side Where(...).ToListAsync());
  2. built our own debug worker — a console app under F5 that calls the same InitRoute.main;
  3. packed the module into a .tpkg (DLL + manifest.json + a config.json with a context name);
  4. watched Tsak pick it up on its own from the modules/ folder via hot-reload;
  5. operated it from the dashboard (start/stop/restart of contexts and routes, with sticky state);
  6. sorted out the ports (9090 — worker API, 5099 — our endpoints, 8080/5000 — dashboard);
  7. deployed it two ways: with Docker (the whole docker-compose.yml) and without (an archive from the releases page).

One and the same codebase — comfortable to debug, and ready for an enterprise runtime. Clustering next.

The full project: redb.Route/demos/EchoWorkerDemo in the repo. Clone it, run it, break it.

Top comments (0)