The fastest
line down the channel.
Thalweg carries records from streaming and batch sources into analytical and operational stores — with the whole path, from wire to table, memory- and type-safe in one language.
thal·weg /ˈtɑːlveɡ/ — the line of fastest flow, the deepest continuous channel down a watercourse. In law, the boundary drawn along a river: the line that carries the authority.
TWG — short for Thalweg, and read as Throughput With Guarantees: move a great deal of data, and never lose any of it.
*Two documented exceptions, both opt-in and off by default. Every other path is pure Rust with no C dependency on the hot path.
01How it works
A record enters from a source — a broker, a file, an Arrow stream. It is decoded once into a shared columnar form, checked against its data contract, transformed by SQL if you want it to be, and written to one or more sinks. Along the way every record carries its origin, so if a destination falls behind it can be rebuilt from the durable copy rather than re-read from the source.
Streaming and batch are the same pipeline. A file source is simply a bounded one — it ends, where a broker does not — so batch ingestion runs through the identical decode, contract, transform, sink and dead-letter path. Not a parallel implementation that drifts, and not a separate tool: one binary, with the mode as a subcommand.
twg stream runs the daemon against brokers and streams.twg batch runs a bounded ingest over files.twg config secrets lists the secret references a configuration needs, for provisioning.Every box on the channel is a stage you can observe independently — its throughput, its memory, and whether it's healthy or building backpressure — at /health and /stats.
What travels with every record. Origin is not a side-channel — it rides with the record as reserved columns under a _twg_ prefix, so it survives every hop, including the Arrow Flight boundary where an out-of-band sidecar would be lost. Two families, and the name tells you which. _twg_source_* is what the broker handed us — topic, partition, offset (or a Pulsar message id), key, timestamp, headers and the rest — as one transport-neutral set, with a column left null where a given transport doesn't supply it. Bare _twg_* is what Thalweg itself stamps: _twg_ingest_ts when we read the record, _twg_emit_ts when we write it, and _twg_record_id, a deterministic content hash that anchors dedup and recovery.
_twg_source_timestamp (when the source recorded it) → _twg_ingest_ts (when we read it) → _twg_emit_ts (when we wrote it) — event time through to processing time. The raw lane keeps the whole set; decoded outputs keep a configurable subset (offsets only, by default). The complete column list, with types and per-transport availability, is in the delivery plan; the naming convention is ADR-0058.02What's supported
Thalweg is early-stage: the architecture is decided and the workspace is scaffolded, with the first capability — user-defined functions — now built and the rest following. The table below is honest about that. It shows where each capability stands today, what is built, and what is planned but not yet started — for example RabbitMQ, NATS, and other transports are planned, not built.
Sources
| Capability | Status | Notes |
|---|---|---|
| Apache Kafka | Scaffolded | Pure-Rust client; consumer groups incl. cooperative-sticky |
| Apache Pulsar | Scaffolded | Fresh binary-protocol client; staged subscription and memory-aware shedding designed in |
| Batch files | Scaffolded | Parquet, CSV, JSON, XML, Excel, Avro OCF — same binary, same pipeline as streaming |
| Arrow Flight (gRPC) | Scaffolded | Already-Arrow, zero decode |
| Arrow IPC (file & stream) | Scaffolded | Non-Flight raw IPC |
| Delta Sharing (recipient) | Scaffolded | Cross-org read via pre-signed URLs |
| Arrow C Data Interface | Scaffolded | In-process zero-copy handoff from PyArrow/Go/C++/Java |
| RabbitMQ | Planned | Not yet started |
| NATS / JetStream | Planned | Not yet started |
| AWS Kinesis | Planned | Not yet started |
| MQTT | Planned | Not yet started |
| REST (poll / webhook) | Planned | Noted for future consideration |
| WebSocket (WSS) | Planned | Noted for future consideration |
Payload formats
| Format | Status | Notes |
|---|---|---|
| Avro (registry + OCF) | Scaffolded | Confluent / Glue / Apicurio wire framing; OCF shares decode |
| Protobuf | Under review | Decode strategy open: zero-copy descriptor-driven parser, gated on licence + benchmark |
| JSON | Scaffolded | Schema-driven + inferred |
| XML | Scaffolded | quick-xml + bespoke XSD/contract-driven mapping |
| Custom binary (WASM) | Scaffolded | Batch-oriented WASM decoder; native compile-in exception |
Schema tooling
| Capability | Status | Notes |
|---|---|---|
| Protobuf source bundling | Built | Any .proto source → one FileDescriptorSet + combined/per-package .proto + message manifest. See Command-line tools |
| Protobuf → ODCS contract | Built | Projects a descriptor set to an ODCS v3.1.0 data contract via a neutral schema hub |
| JSON Schema / Avro bridge | Planned | Further spokes on the same hub; leaf-type mappings already exist in twg-type-map |
| ODCS → Protobuf generation | Planned | The reverse direction; field numbers synthesised deterministically |
Sinks & table formats
| Target | Status | Notes |
|---|---|---|
| PostgreSQL | Scaffolded | Binary COPY; Postgres 18+; primary-raw eligible |
| Databricks (Zerobus) | Partial | Arrow Flight → Delta. Fire-and-forget ack lane, stream pool, catalog provisioning, type reconciliation and primary-raw preconditions built and fake-tested; the real client is behind the databricks-sdk feature (Arrow-IPC bridge). Iceberg recovery read-back and live execution pending (ADR-0065) |
| Parquet (object store) | Scaffolded | Plain files; primary-raw eligible, no catalog needed |
| Apache Iceberg | Scaffolded | Read + write, catalog-mediated. The one open table format carried; reader feeds recovery |
| Arrow Flight / IPC | Scaffolded | First-class out-transports |
| DuckLake | Planned | Source & sink; treated like other DB targets |
| REST / WebSocket | Planned | Push/forward sink; noted for future consideration |
Platform capabilities
| Capability | Status | Notes |
|---|---|---|
| Data contracts (ODCS) | Scaffolded | Optional. Schema authority, quality rules merged tightest-wins, drop rules; loaded local/HTTP/object-store |
| DQ audit trail | Scaffolded | Full-grain per-record per-rule; durable sink; on by default |
| Raw-anchored recovery | Design complete | Gap-aware coverage, bounded windowed replay, separate scale-to-zero role |
| Sink coverage store | Partial | Gap-aware union-merge ranges + gap computation + in-memory backend built (the `CoverageStore` seam); durable colocated-with-raw backings and the write-behind cache pending |
| Content deduplication | Design complete | Catches upstream duplicates the record ID cannot; opt-in and contract-gated |
| Coordination & quorum | Design complete | Quorum as a worker role; substrate deferred until recovery is distributed |
| SQL transforms (DataFusion) | Scaffolded | DAG with interior sinks, materialised intermediates |
| User-defined functions (UDFs) | Built | Local DataFusion scalar UDFs (JSON, salted-hash, Spark-compat, Avro decimal) plus a hardened remote Arrow Flight UDF path — API docs |
| Health & observability | Scaffolded | Per-stage health tree, OTLP, Prometheus, /stats |
| K8s / GitOps config | Scaffolded | Verbatim TOML delivery; secret enumeration |
| Pulsar flow control | Design complete | Staged subscription, topic-spread shedding, byte-budget admission, pressure-as-scale-signal |
| Java / Python facades (C ABI) | Design complete | Reserved; deferred (design-for, don't-build) |
03Design principles
These ideas shape every decision in Thalweg. They are worth reading before the architecture, because the rest follows from them.
No C-linked broker client on the hot path. Codec and auth crates carry no async runtime, so they are reusable anywhere — a Lambda, a query engine, a transport not yet written.
Raw bytes, a neutral value model, and Apache Arrow — none privileged. Pass through untouched, decode per-record, or decode straight to columnar batches.
Every source and sink is optional; a deployment wires only what it needs. Kafka and Pulsar implement the same Source/Sink interface, so flow control, health, retry and recovery are written once and shared across every transport.
Any replayable sink can hold the primary-raw role. The source is acknowledged only once raw is durable; lagging sinks rebuild from raw, never by re-reading the source.
Fifty-nine architecture decision records, a scenario catalogue, and encoded regression tests — the hard-won lessons are permanent, not tribal knowledge.
The layering, the single Arrow version, the confinement of unsafe code — all enforced in CI, so the architecture cannot erode silently under delivery pressure.
04The layers
Crates are grouped into layers with a strictly downward dependency direction, enforced in CI. Nothing in the codec layer may depend on a transport; nothing in the codec or SASL layer may depend on an async runtime.
Reading bottom to top: raw bytes come in through transport primitives, up through sources into the shared connector contract, get decoded and validated, transformed, and written out. The codec-core crate holds the single Arrow re-export — the one place the Arrow version is named, so the whole workspace pins one version.
05Crate reference
Filter by layer, or read straight through. Every crate is currently a scaffold — the descriptions are the contract each will fulfil.
06Contracts & data quality
A data contract describes what a stream is supposed to contain. Thalweg reads ODCS — the Open Data Contract Standard — and uses it to decide both the shape of the data and what counts as acceptable.
Contracts are optional. Without one, schemas are inferred or taken from a schema registry and quality rules come from configuration. With one, the contract is authoritative on schema, and its quality expectations merge with any configured manually.
schema authority
Where a contract defines schema it wins over inference. For CSV, JSON and XML that is also faster — inference samples records and guesses; a contract does not.
rules merge, tightest wins
A manual rule and a contract rule on the same field resolve to the more restrictive. Neither source can silently loosen a constraint the other set.
loaded from anywhere
Local file, HTTP, or object storage — validated at startup, so a missing or malformed contract fails before the first record rather than during.
three enforcement modes
Strict fails the batch, quarantine diverts bad records, annotate flags them and lets them through.
drop rules
Known junk — heartbeats, test traffic — is discarded deliberately and silently, so the dead-letter queue stays a signal about real failures rather than noise.
one dead-letter path
Decode failures, contract violations and quality failures all land in one place, tagged by reason. Dropped traffic never appears there.
audited
Every record's verdict on every rule is recorded durably, stamped with which rule version produced it — so a verdict stays meaningful after the rule changes.
gates deduplication
Content deduplication is only permitted where a contract declares a natural key, since dropping byte-identical payloads is unsafe without one.
07Recovery & guarantees
The correctness backbone is one rule, and it applies to every kind of position the system holds: position may lag, but must never lead. A source position ahead of what was durably written resumes past records that never landed; a sink marked as written when it wasn't is never replayed. Both are silent loss. Behind, in either case, is a redundant replay that deduplication absorbs.
So the source is acknowledged only once the primary-raw sink confirms durability, and coverage is recorded only after the sink it describes has acknowledged. Never before, never concurrently.
Recovery is not optional past one sink
Once the source is acknowledged, the broker moves on and can no longer tell you what a downstream destination missed. From that moment the coverage store is the only record of it. A multi-sink topology without recovery isn't less resilient — it's silently lossy by construction, so configuration refuses one.
gap-aware coverage
Covered ranges per destination, not a high-water mark. A sink writing batches 1, 2, 4, 5 while 3 fails leaves batch 3 identifiable; a watermark of 5 would lose it silently.
never over-claims
Coverage may lag behind reality, costing a replay. It may never claim a write that didn't happen, which would be permanent loss.
lives with raw
Never inside the destination it describes — a store in a failed sink can't record that the sink failed.
deterministic IDs
Each record hashes to a stable ID including its source cluster, so replay deduplicates and overlapping ranges merge harmlessly.
bounded windows
Replay runs in time- and row-bounded windows, so catch-up never runs memory away — and the bound is what lets several agents share the work.
a separate role
Recovery runs as its own deployment mode, so a large backfill can't throttle live ingest. It scales independently, to zero when nothing is behind.
unified DLQ
Decode, contract and quality failures land in one dead-letter path, tagged by reason — never mixed with deliberately dropped traffic.
local buffer
Coverage is buffered on the pod and checkpointed on an interval, so commits are fast and inspectable on the host rather than only through a remote store.
08Scaling & coordination
Workers scale freely on throughput. What they need to agree on is deliberately tiny — which recovery window each agent has claimed, which node drives recovery — so the coordination layer is sized for that rather than for the data path.
Quorum is a role, not a tier
Rather than a separate coordination deployment, a subset of workers additionally hold quorum. A node determines its roles when it starts: worker, recovery worker, and where the cluster is short of members, a quorum member too.
A node holding quorum lowers its own throughput budget. Consensus means an fsync per commit and replication to peers, which contends with ingest for the same disk and CPU — so it takes less work and leaves headroom, trading its throughput for the cluster's stability.
Two nodes is refused rather than discouraged: quorum of two means both must be up for any write, so losing either halts writes. That's strictly worse availability than a single node, which keeps serving alone — the one size worse than both its neighbours, and the one an autoscaler would step through by accident.
Membership changes far less often than pod count, which is what makes this workable: a three-member cluster tolerates one member gone, so scaling three workers to two needs no reconfiguration at all.
Catching duplicates the record ID can't
The record ID hashes source position, so broker redelivery is idempotent. It does nothing for a duplicate created upstream — a producer's own retry republishes the same record at a new offset, which hashes differently and passes through. Hashing the payload catches it.
09Delivery plan
Phases, with the constraint that shapes them: nothing ships that would be silently lossy. Where a capability can't be made safe yet, configuration refuses it rather than allowing a topology that only looks correct.
| Phase | Delivers | Gate |
|---|---|---|
| 0 · Foundations | Workspace, CI, structural gates, static musl build proven on both architectures | A deliberately failing commit is rejected |
| 1 · Transport & value model | TLS, SASL, compression; the neutral value model and codec traits | Value model round-trips all four formats before any codec exists |
| 2 · Avro & registry | Schema registry, Avro across all three representations | Byte-identical against a reference decoder |
| 3 · Kafka consumer | Connections, fetch, manual assignment. Passthrough ships here | Survives broker restart and leader election |
| 4 · Consumer groups | Join/sync/heartbeat, cooperative-sticky rebalancing | 20-member group survives rolling restarts without duplicate assignment |
| 5 · Sinks, coverage & recovery | Sinks and connector layer, then coverage and replay. Single-sink until recovery lands | A failed sink's gaps are recorded and restored without touching the source |
| 6 · Producer | Idempotent and transactional produce | No duplicate commits across coordinator failover |
| 7 · Breadth | Protobuf, JSON, custom-WASM decode, database sinks, catalogs, cloud auth (AWS/Glue block) | Protobuf decodes byte-identical to a reference; a database sink survives restart |
| 8 · Pulsar | Fresh client with staged subscription and memory-aware shedding | Codec crates reused unmodified — the layering claim tested for real |
| 9 · Transform & packaging | SQL DAG, WASM UDFs, Flight sidecars, static musl and OCI images | A three-level DAG with correct commit semantics across restart |
Phase 5 is the one to watch: it carries the sink layer, the connector abstraction and the recovery machinery, and recovery is a precondition for the second sink rather than hardening applied afterwards.
10Command-line tools
Most of the engine is scaffold and design, gated behind the delivery plan. One capability is built and usable today: turning a Protobuf source tree into the artefacts the rest of the platform consumes, and projecting it to a data contract.
twg proto bundle is the first fully-wired subcommand. It is pure Rust — it parses and resolves .proto syntax itself, with no protoc binary on the path.twg proto bundle
Resolve any Protobuf source — a single .proto file, a bare directory of protos, or a Java/Maven/Gradle repository with many src/main/proto roots — into one combined descriptor set plus human-readable renderings and a machine-readable manifest.
OPTIONS
| Flag | Default | Effect |
|---|---|---|
<SOURCE> | — | A .proto file, a directory of protos, or a repository root. Required. |
--out-dir | — | Directory to write outputs into. Required. |
--name | source stem | Basename for the emitted files. |
--emit | pb,single,proto,manifest,odcs | Which artefacts to write, comma-separated. An unknown value fails fast. |
--include-root | auto-detect | An extra include root added on top of auto-detection, for a dependency whose files live outside the source. Repeatable. |
--dep-search | source repo | An extra directory to search for dependency-providing src/main/proto roots — e.g. a separate repo holding shared contracts. By default the source's own repo (found via .git) is searched. Repeatable. |
--no-auto-resolve | off | Report missing dependencies (with the files that need them) instead of resolving them from the repo. |
--exclude | none | Prune any subtree whose path contains this component — e.g. a vendored copy that diverges from the canonical tree. Repeatable. target, build, rust-build, .git and node_modules are always pruned. |
--include-glob | **/src/main/proto | The trailing path that marks an include root in repository mode. |
--on-conflict | error | When one import path resolves to files with differing content: error (refuse, and name a subtree to --exclude) or first-wins (keep the first copy and warn). Prefer --exclude, or point the source at the single canonical subtree. |
--wkt-imports | central | Where the Google well-known-type imports live in the rendered .proto: central (one shared base file) or per-file. |
OUTPUTS
| File | What it is |
|---|---|
<name>.pb | One binary FileDescriptorSet — every file in the source plus the reachable Google well-known types. The authoritative, machine-consumable artefact. |
<name>.proto | A single combined .proto. Standalone-compilable when the source is one package; a package-delimited combined view (labelled as such) when it spans several. |
<name>/<package>.proto | One valid proto3 file per package, importing its siblings. |
<name>.messages.json | Every fully-qualified message type, with the entry/root types flagged — the strings you hand to the schema and decode paths. |
<name>.odcs.json | An ODCS v3.1.0 data contract projected from the schema: one schema object per message, cross-references preserved as relationships. |
any source shape
A single file, a bare directory, or a multi-root repository all resolve through one command; the layout is auto-detected.
duplicate-safe
The same import path under two roots is collapsed only when byte-identical; a divergent copy is refused, and the error names the subtree to --exclude to keep the canonical one.
deps auto-resolved
Point at a subdirectory and its cross-module imports are pulled in automatically from the repo (found via .git); the bundle stays focused on the target plus exactly its transitive deps.
contract projection
The same descriptor set is projected to an ODCS contract, the first spoke of a neutral schema hub that JSON Schema and Avro will join.
message_type strings to feed the schema and decode paths — and points at <name>.messages.json for the full list. A multi-package source whose packages reference each other cyclically renders a combined-view .proto that will not recompile standalone; the .pb is the artefact to use there.Design records: docs/adr/0063 (bundling) and docs/adr/0064 (the schema bridge). The other twg subcommands — stream, batch, config — are scaffolds pending their delivery-plan phases.
11Decision records
Every significant choice is recorded as an ADR, so the reasoning survives the people. One remains genuinely open, pending a trade study; one is drafted and accepted, and one newly drafted and in review; the rest are decided and awaiting drafting into full prose.
The full index lives in docs/adr/README.md. Each will be drafted in the house ADR style and synced to Confluence.
12Why this name
The thalweg is the line of fastest flow — the deepest continuous channel down a watercourse, the path water takes when it has a choice. For an engine whose job is the efficient path from source to destination, that is the thesis in one word.
It carries a second meaning that happens to describe a real design decision. In international law, the thalweg is the boundary drawn along a river: the line that carries the authority. That is precisely how this system treats its primary-raw lane — one sink holds the authoritative copy, the source is acknowledged against it, and everything else is reconciled back to it.
The project was drafted under the working name Millrace. That name turned out to be published on crates.io already — by another streaming event system, a direct collision in this project's own domain — so it was changed before anything was pushed or published. Thalweg is also a watercourse term, which is why the channel diagrams, the sluice-gate stages, and the weathered-copper palette on this page all survived the rename intact.
What else was considered
Other water-engineering candidates were checked for availability and brand collisions. penstock had the best literal meaning — the pressurised conduit feeding a turbine — but collides with a healthcare vendor whose product is audit software, an unfortunate neighbour given this system's own audit trail. headrace collides with an HR-tech company. adit was free but reads as a mis-typing of "audit". sluice, weir, leat, grist, winnow, spillway, braid and riffle were all already published crates.
A bare three-letter acronym with no parent word was considered and rejected: the available combinations were either colliding — trc and drc are smart-pointer crates, tde is Transparent Data Encryption, avc is H.264 video — or characterless. Deriving the prefix from a real word gives both a memorable name and a short prefix. Within that, thw was rejected in favour of twg: it is the well-known abbreviation for Technisches Hilfswerk, Germany's federal civil-protection agency, an awkward adjacency for a German-named organisation.
Crates are prefixed twg-; the command is twg. twg proto bundle is built and usable today; twg stream, twg batch and twg config are scaffolds pending their delivery-plan phases.
13Document map
Where to find things in the repository.
| You want… | Look in |
|---|---|
| This overview & navigation | docs/site/index.html |
| Rendered ADRs, blueprints, ops & testing | docs.thalweg.dev/reference/ |
| Per-crate API reference (rustdoc) | docs.thalweg.dev/api/ |
| Why a decision was made | docs/adr/ |
| The delivery plan & phases | docs/blueprints/ |
| How config & GitOps work | docs/operations/CONFIG.md |
| Health & observability model | docs/operations/HEALTH.md |
| Batch reader library choices | docs/operations/BATCH-READERS.md |
| Test scenarios & regressions | docs/testing/ |
| Publishing, licensing, versioning | docs/PUBLISHING.md |
| How this site is deployed | docs/operations/DOCS-DEPLOY.md |
| Working here as an agent | AGENTS.md |
| Logo, colours, asset files | docs/site/brand/README.md |
| Repository map (graphify) | interactive map · generated with graphify.com |