Doris sink — Bifröst gains a table sink
Class:
design· Function:doris-sink· Uses:cdc,bifrost,skade. This is the delivery half of the CDC story: skade computes the diff (itscdc-stream-design.md), bifröst already streams it (cdc-design.md,bifrost-design.md), and this adds the one genuinely missing piece — a table-shaped sink.Read f0 before f1. The premise this was originally specced against does not survive contact with the Doris version actually deployed.
f0 — the gap this closes, and how much smaller it turned out to be
The measurement
On the red bench cluster a 2 000 000 000-row person table exists twice: once as Doris-native rows (fake.personer2g, 81.6 GB incl. replicas), once as Iceberg zstd-parquet on MinIO (iceberg.bench.doris2g, 23 GiB). The same point lookup (WHERE personnummer = <x>), measured server-side:
| where the rows live | point lookup | note |
| Iceberg on MinIO, Doris file cache off | 41 838 ms | instrumented: 20.69 GB read at 495 MB/s — 100 % disk-bound |
| Iceberg on MinIO, Doris file cache warm | 1 517 ms | the recorded number; a later run was reported at 962 ms |
| Doris native storage | 24 ms | 34 ms without the inverted index |
Source: ubm-play-red-apps/docs/benchmarks.md. Three corrections to the earlier draft of this spec, which must not be repeated:
- The inverted index is worth ~30 %, not 46×. The gap comes from native storage plus predicate pushdown plus zone maps;
idx_pnrmoved 34 ms → 24 ms. - The MinIO volume is not NVMe. One Scaleway block volume,
ROTA=1, ~495 MB/s ceiling. The Doris file cache is on BE local disk; the bench does not characterise that disk, so neither does this document. - 962 ms is not in the cited source. The bench records the warm number as 1 517 ms. Both are reported here rather than quietly picking one.
Getting the 24 ms path on lake-sourced data means physically copying rows into the engine. So far, so uncontroversial.
The premise that broke
The original argument was: Doris async materialized views over an Iceberg catalog support only COMPLETE refresh — partition-level refresh is Hive-only — so every cycle rewrites all 2 B rows to absorb a day's appends. Therefore build a CDC delivery sink.
That was true for Doris 2.1 and 3.0. It is not true for the cluster in question. ubm-play-red-apps/versions.env pins doris_version=4.0.8, and Doris's async-MV support matrix records partitioned refresh over Iceberg as "Supported in 3.1" (alongside Paimon and Hudi). So the deployed engine can, in principle, refresh a materialized view partition-by-partition rather than whole.
What is still true, and what actually leaves a gap:
- Partitioned MV refresh over Iceberg requires the Iceberg table to be partitioned, on a single partition column, with only limited partition evolution tolerated.
iceberg.bench.doris2gis not partitioned — the bench doc says so explicitly ("Getting real pruning on Iceberg needs partitioning … Not yet done"). On the table as it exists today,COMPLETEis the only available refresh. That is a property of the table, not of Doris. - Auto-trigger is not supported for any external catalog, so even a partitioned MV refreshes on a schedule, not on change. Freshness is bounded by the schedule either way.
- Doris's data-lake metadata cache can leave an MV believing it is in sync after an external
INSERT OVERWRITEthat does not change the metadata it watches.
So the honest gap is narrower than advertised: not "Doris cannot do this", but "Doris can do this only for partitioned Iceberg tables, only on a schedule, and the table we measured is not partitioned". The cheapest fix for the measured case may well be partition the Iceberg table, not build a daemon. That possibility is now the first item in f8, and it may be the whole answer.
The case that survives regardless: a TableSink is engine-generic, handles deletes, and is not restricted to one partition column. If bifröst is to deliver CDC anywhere other than a graph, the seam has to exist. That — not the 46× — is the justification for building it.
f1 — where this belongs, and why not the other three candidates
The earlier draft put this in skade, as a new ChangelogSink trait plus a new skade-streamer daemon. That was wrong, and the evidence is mechanical.
It belongs in knut-bifrost
Bifröst is the CDC streamer. The daemon the earlier draft proposed already exists here, under another name:
| the daemon needed | what bifröst already has |
| poll a table for new snapshots | Listener::run / run_with_backoff (listener.rs:317,335), with exponential backoff on idle rounds |
| read the CDC window | IcebergSource::delta_from_table → skade::Table::read_changelog (source.rs:767); SkadeCdc (cdc/skade.rs:87) |
| split inserts from deletes | partition_change_rows (transform.rs:215) — routes all four _change_type tags through the one cdc::ChangeOp vocabulary |
| durable per-table cursor | SyncState (file or Valkey) + TableState::snapshot_id (state.rs:26,72,89) |
| apply, then advance the cursor | listener.rs:298 → listener.rs:303, comment: "Only after the sink succeeded" |
| metrics / Prometheus / structured logs | metrics.rs, metrics_http.rs, tracing targets bifrost::* |
| a CLI driver | bin/bifrost behind feature cli |
Bifröst also already carries the two dependencies a Doris sink needs — reqwest (optional, feature airflow) and skade 0.5.3 (optional, feature skade) — so the Doris feature pulls no new dependency tree into the workspace.
Why not skade
skade already owns the delta primitives and should keep owning them: Table::read_delta / read_changelog (skade/src/read.rs:2750,3045), ChangelogBatch::collapsed, Table::read_equality_deletes, DeltaPlan::needs_full_reload (read.rs:2728), ConsumerCursor (skade/src/stream.rs:40), Table::changes_polling. Those are lake concerns.
A Stream Load client is not. It is an HTTP client for one specific MPP engine, and skade's own charter ("add to skade without destroying skade") keeps the data plane free of engine knowledge. skade has no HTTP client at all; its existing LineageSink is an OpenLineage metadata sink and is unrelated to a data sink. Putting an engine-specific loader there would be the first crack.
Why not knut-thund
Þund has a sink notion — OutputType::Sink plus a format string plus knut.* keys in Dataset.properties — and even a working non-file external sink (FalkorDB). But:
- Þund's own precedent delegates the wire protocol:
backend/native.rs::write_graph_sinksmaps IR →GraphMutationand hands the protocol toknut_bifrost::sink::FalkorDbSink. The parallel move puts the Stream Load client in bifröst, not in thund. - The dependency direction forbids the alternative anyway.
knut-pipelinesdepends onknut-bifrost, and thund depends onknut-pipelines, so bifröst cannot depend on thund; thund → bifröst is the only legal edge. - Þund's sinks write after the whole dataflow has materialised, from an in-memory
Vec<RecordBatch>per dataset. Wrong shape for a windowed, cursor-driven bulk load. - Þund has no HTTP client anywhere in its tree, and its own gap doc (
thund-vs-spark-gap.md§4 item 7) explicitly deprioritises connector breadth.
A thund-side format = "doris" dataset remains possible later, and should be built the way the graph sink was: IR mapping in thund, protocol in bifröst.
Why not a new crate
Same grounds on which graphar-falkordb is a separate crate but FalkorDbSink is not: the sink is the adapter and belongs beside its siblings. A separate crate would need its own copy of the listener contract, the _change_type vocabulary and the error type — exactly the duplication the workspace charter forbids. If the Stream Load client ever grows client-side concerns of its own (a BE topology cache, connection pooling, a non-HTTP protocol), splitting a knut-doris out then is a mechanical move. Today's code does not justify it.
What this means for the hypothesis on the table
The working hypothesis was: delta computation belongs in skade where it already lives; streaming/orchestration already exists in knut-bifrost; only a table-shaped sink is genuinely new, and it belongs in knut. That survives. Two corrections to it:
- Bifröst does not currently refuse a full reload — it silently performs one.
source.rs:801-821logsfull_reload = plan.needs_full_reloadand passes the whole-snapshot re-read straight through to the sink. Correct for FalkorDB (MERGEis idempotent, the graph is small); catastrophic for Doris. The refusal is new behaviour, not inherited. See f6. - The refusal cannot live where the earlier draft put it. See f6 — as specified, it would have paid for the full read before refusing to write it.
f2 — the seam: TableSink, a sibling of GraphSink
GraphSink is async fn apply(&mut self, &GraphMutation) -> Result<SinkReport> (sink.rs:19-22). It cannot be generalised into service here, and the reason is not stylistic: GraphMutation is Vec<VertexSet> + Vec<EdgeSet> + Vec<VertexDelete>, and every one of those carries label, id_column, src_label/dst_label/edge_type, edge_key_props (mutation.rs). A Doris load has none of those, and SinkReport counts vertices_written / edges_written / deleted. Making the graph fields optional to accommodate a table produces a type whose majority of fields are meaningless to either caller — strictly worse than two honest traits.
So: a sibling, and a module split so two sink families never accumulate in one file.
src/sink/
mod.rs re-exports; the shared row-rendering helper stays private
graph.rs GraphSink + FalkorDbSink (moved verbatim from sink.rs)
table.rs TableSink + TableWindow + LoadAck (no new dependency)
doris.rs DorisSink + the Stream Load client (feature `doris`)
The trait:
#[async_trait]
pub trait TableSink: Send {
/// Fail fast on a schema the sink cannot accept. Called once before the
/// first window; a mismatch here refuses to start rather than half-loading.
async fn probe(&mut self, schema: &Schema) -> Result<()>;
/// Apply exactly one CDC window. Idempotent by contract — the same window
/// may arrive twice after a crash between the load and the cursor commit.
async fn apply(&mut self, window: &TableWindow<'_>) -> Result<LoadAck>;
}
TableWindow is the window, not a mutation: the source table, the snapshot pair that identifies it, and the already-partitioned rows.
pub struct TableWindow<'a> {
pub table: &'a str,
pub snapshots: SnapshotWindow, // { from: Option<i64>, to: i64 }
pub upserts: &'a [RecordBatch],
pub deletes: &'a [RecordBatch],
}
upserts / deletes come from the existing partition_change_rows (transform.rs:215), reused rather than reimplemented. That is what makes the four-tag mapping correct for free: INSERT / UPDATE_AFTER / folded UPDATE → upserts, DELETE → deletes, UPDATE_BEFORE dropped (its paired UPDATE_AFTER fully overwrites the row), unknown tag → hard error.
f3 — the ordering that must not be swapped, enforced by the type system
The rule: commit the cursor strictly after the sink acknowledges.
Crash between load and commit ⇒ the window is replayed ⇒ the label is byte-identical ⇒ Doris rejects the duplicate ⇒ the replay is a no-op. That is at-least-once delivery made idempotent by the sink.
Commit first, and a crash before the load loses the window silently. No error, no retry, no lag metric — the rows are simply not there. It is the worst class of bug this design can have.
listener.rs gets this right today, but by comment (listener.rs:301). A comment is not a mechanism. The table path encodes it in types:
/// Proof that a window was durably accepted by a sink. Its only constructor is
/// private to `sink::table`, and `TableSink::apply` implementations are the only
/// callers — so a `LoadAck` cannot exist unless a load actually acked.
pub struct LoadAck { /* labels, to_snapshot, rows, deletes, seal: Seal */ }
/// The single choke point. The ONLY function that advances a table-delivery
/// cursor, and it cannot be reached without a `LoadAck`.
pub async fn deliver_window<S: TableSink>(
sink: &mut S,
cursor: &mut SyncState,
window: &TableWindow<'_>,
) -> Result<LoadAck>;
The cursor's new value is read off LoadAck::to_snapshot, never off TableWindow. A later edit that wants to advance the cursor early must first invent a LoadAck, which it cannot do from outside the module. That is the entire point of the seal.
f4 — idempotent replay via Doris's own label
Doris Stream Load takes a caller-supplied label header and rejects a load carrying a label it has already seen. Measured on the live cluster (Doris 4.0.8), not assumed: three rows loaded three times into sltest.t —
- load #1, label
skade-sltest:t-1042-1043→Status: Success,NumberLoadedRows: 3 - load #2, identical label →
Status: Label Already Exists,NumberLoadedRows: 0,[LABEL_ALREADY_EXISTS] ... Label [skade-sltest:t-1042-1043] has already been used, relate to txn [862], status [VISIBLE] - load #3, different label, same three rows →
Success, 3 rows. Final count 6.
So a replay under the same label is a genuine no-op, and a different label does not deduplicate — the whole argument rests on the label being derived.
One trap in that result: the rejection arrives as HTTP 200 with the verdict in the JSON Status field. A client that treats 200 as success would count a rejected replay as a successful load and report rows that never landed. The result parser keys on Status, never on the HTTP code.
Be precise about what that buys, because the earlier draft overclaimed. Doris's own documentation calls this At-Most-Once, not exactly-once: "Data with the same label cannot be imported more than once … so that duplicate requests for the same batch are accepted only once." Combined with a retry loop and a cursor that only advances on success, at-most-once per label plus at-least-once delivery is what yields effectively-once. Doris reserves the term "exactly-once" for its two-phase commit mode (two_phase_commit: true, then txn_operation: commit|abort against /_stream_load_2pc). This design deliberately does not use 2PC: it adds an in-flight transaction to reason about across a crash, and the label-derivation trick already gives a correct replay. 2PC is the documented escalation if the caveats below ever bite.
The label is derived, never generated:
skade-<db>:<table>-<from>-<to>-<chunk> e.g. skade-fake:personer2g-1042-1043-0
skade-<db>:<table>-init-<to>-<chunk> when from is None (first run)
A . is invalid and the earlier draft of this spec was wrong to use one. Doris validates the label against ^[\-_A-Za-z0-9:\p{L}]{1,128}$ and rejects anything else outright:
[ANALYSIS_ERROR] ... Label format error. regex: ^[\-_A-Za-z0-9:\p{L}]{1,128}$
Hence : between database and table — it is in the permitted set. The 128-char cap is enforced by the builder, and a unit test asserts every generated label matches the regex.
Derived from the snapshot pair means a replay of the same window produces the same label with no state kept anywhere. The <chunk> suffix exists because a window may exceed one Stream Load (f5); chunk k of a given window always gets the same label, so a partial replay re-sends only what was missing.
Three caveats that belong in the design because each can bite:
- Labels expire, sooner than the obvious config says. Doris documents
label_keep_max_second(default 3 days) on the Stream Load page, but a Stream Load is governed bystreaming_label_keep_max_second, whose default is 43 200 s = 12 hours. After expiry, "the removed labels can be reused" — a replay is no longer deduplicated and would double-load. A crash replay happens within seconds, so the window is narrow; a job that has been down for more than 12 hours and resumes mid-window is the case to worry about. Do not paper over it. - A CANCELLED load frees its label immediately. Dedup is not absolute.
- Sanitising is not enough on its own — it has to be injective. Folding every disallowed character to
_makespersoner.2gandpersoner_2grender the same label, and a shared label between two tables is a silently skipped load. The builder therefore appends a hash of the original whenever a character actually had to be replaced, leaving well-behaved names untouched and readable. Truncation for the 128-char cap works the same way and never touches the snapshot or chunk fields. This was found by the collision test, not by inspection.
f5 — the Doris sink specifically
Topology, and the error that looks like auth and is not
Stream Load answers on the FE (http_port, default 8030) at PUT /api/{db}/{table}/_stream_load, and the FE replies 307 with a Location naming a BE (webserver_port, default 8040) chosen round-robin, which does the actual work. (The 307 status is stated outright in the Doris 4.x key-features page; the per-version manuals say only "HTTP redirect". red runs 4.0.8, so the 4.x wording is the one that applies.)
Consequences:
- the loader must reach the FE and every BE it can be redirected to;
- the redirect
Locationcarries the BE's configured address, which on a cluster with split internal/external addressing is not necessarily routable from where the loader runs; - credentials must survive the redirect (
curlneeds--location-trusted; a client that re-authenticates on redirect will appear to fail authentication).
And the failure mode is a hang, not an error. An earlier draft of this spec claimed an unreachable BE "fails in a way that reads like an auth error". That is wrong, and it was measured: with the FE at 172.16.12.13:8030 answering /api/health with 200 and the BEs on :8040 unreachable, the Stream Load hangs after the 307 until something times out. Nothing in the FE's health looks wrong while this happens.
The practical consequence is a design requirement, not a documentation note: without an explicit connect and request timeout on the redirected hop, one routing mistake stalls the stream forever, with no error at all. So the sink
- disables automatic redirect following and re-issues the
Locationitself, - puts a hard connect timeout (10 s) and request timeout (300 s) on every hop,
- and reports the stall as what it is, naming the BE and the timeouts.
Doris also permits PUTing directly to a known-healthy BE, which is the escape hatch when FE-issued addresses are unroutable.
Expect: 100-continue is mandatory
Also measured: omit it and Doris answers {"status":"FAILED","msg":"There is no 100-continue header"}. This is worth its own heading because of how it presents — curl sends the header for large bodies and omits it for small ones, so a client that does not set it explicitly sees small windows fail while large ones succeed. That looks like data corruption long before it looks like a missing header. The sink sets it unconditionally on every load, and a test asserts it is present on every PUT, deliberately using a one-row window.
Placement, in order of preference:
- Co-located with the Doris BEs. The 307 hop stays local.
- Its own small host in the dataplattform zone. One extra hop, cleaner lifecycle; preferred once several tables stream and load is spiky.
Either way the process needs: MinIO/S3, the skade catalog (redb file or skade-katalog serve), Doris FE :8030, and every BE :8040.
The write belongs to Doris
The sink ships rows over HTTP. Doris's BE decides encoding, compaction and indexing on its own storage. bifröst never writes into a Doris data directory and must never be handed a path into one — the moment one program writes into another's storage directory you own both forever. That coupling argument is the whole reason this is a network protocol and not a file drop.
Table models and what a DELETE row means
| mode | Doris table model | DELETE rows |
append |
duplicate key | asserted absent — the window fails if any appear |
upsert |
unique key + __DORIS_DELETE_SIGN__ |
applied, delete-sign column set to 1 |
append is not merely an optimisation. On an append-only source an unexpected delete means the premise of the deployment is wrong, and a loud failure beats a silent divergence between lake and engine.
Mechanics, corrected from the earlier draft:
- The hidden column is spelled
__DORIS_DELETE_SIGN__(two underscores each side),TINYINT, default 0. Doris FE automatically adds__DORIS_DELETE_SIGN__ != trueto every query plan. - It is a Unique Key feature. It is not gated on
enable_unique_key_merge_on_write; that association was invented by the earlier draft. Aggregate/Duplicate key tables must useDELETEstatements instead — which is precisely whyappendmode refuses delete rows rather than trying. - Stream Load marks deletes via the
merge_typeheader (APPENDdefault /DELETE/MERGEwith adelete:condition) or by naming__DORIS_DELETE_SIGN__inhidden_columns/columns.DELETE ONis Broker Load / Routine Load syntax and does not apply here. - If a sequence column is configured, a delete only takes effect when the sequence value is ≥ the current one — otherwise it silently does not apply. The sink does not configure sequence columns and documents that it does not.
Payload and chunking
TableWindow carries Arrow. Stream Load's format header accepts csv, json, parquet, orc and arrow. The implementation ships JSON (format: json, strip_outer_array: true) because it needs no dependency bifröst does not already have and is unambiguous about nulls and types. Parquet and arrow are the throughput follow-ups, and are the reason the encoder is a small enum rather than a hard-coded function. Which wins is an empirical question (f8), not an assumption.
Chunking is not optional. The BE caps a single Stream Load at streaming_load_max_mb (default 10 240 MB = 10 GB), and Doris's own guidance is that Stream Load "is generally suitable for loading data less than a few GB" and that a single file should stay under 10 GB — a dropped connection restarts the whole request. So the sink splits a window into chunks under a configured byte budget (default well below the cap), each with its own derived label, and only issues a LoadAck when every chunk has acked. A partially-loaded window leaves the cursor where it was and replays; the already-loaded chunks are rejected by label and the missing ones go through.
Schema
probe() compares the Arrow schema against the Doris table's columns and refuses to start on a mismatch. Discovering a column-order or type difference mid-stream, after a partial load, is far worse than refusing to boot.
f6 — needs_full_reload: refuse, and refuse in the right place
DeltaPlan::needs_full_reload is set when a commit in the window rewrites rows in place — an Overwrite/Replace operation, or a position-delete file — a change the additive append + equality-delete delta cannot express.
The rule: refuse the window, emit functional_status(..., ok = false), require explicit operator action. Never fall back silently. On a 2 B-row table a silent fallback turns a streaming job into an 81 GB rewrite at whatever hour the writer happened to run.
The earlier draft put this check on the ChangelogBatch handed to the sink. That does not work, and the reason is worth stating precisely because it is not obvious:
skade::Table::read_changelogalready handles a full-reload window by re-reading the wholetosnapshot as a pureINSERTstream (skade/src/read.rs:3045-3062). By the time a sink sees the batch, the expensive read has already happened. A sink-side refusal would refuse to write an 81 GB read it had already paid for.
The correct place is a pre-check on read_delta, before read_changelog is called at all:
plan = table.read_delta(from, to) # cheap: snapshot-lineage walk only
if plan.needs_full_reload: REFUSE # read.rs:2793 returns (vec![], plan)
# without touching a manifest or a
# parquet file
else: batch = table.read_changelog(from, to)
read_delta returns early with an empty batch and the flag set, so the pre-check costs a metadata walk. It also exposes a second hazard: a driver that used read_delta alone on such a window would apply zero rows and believe it had succeeded. The refusal is what turns that silence into an error.
Operator resolution is an explicit one-shot reload, never an automatic one.
f7 — delivery as a service, without two service definitions
Two audiences:
- Inside the workspace this is a declared Skidbladnir service, with
jeraowning the process/VM/container decision. - Outside it, someone who
cargo installs the bifröst binary has no Skidbladnir and never will, and needs the binary to install itself.
These do not conflict, and the workspace has already solved it once. From flight-graph-server-design.md §N5: the knut-flight service is delivered two ways from ONE ComponentSpec — Skidbladnir's SystemdInstaller renders the unit, its KubernetesRenderer renders a Deployment from the same spec, "so the Runtime targets never drift". knut's own seam audit (consolidation-seam-eval.md §2) anticipates exactly this case: "if knut ever ships its own long-running service … it should declare a skidbladnir::ServiceSpec then."
So the answer is not "write a unit file inside the binary". It is:
- One
ServiceSpec, declared in bifröst, is the single source of truth. bifrost installrenders that spec throughskidbladnir::SystemdInstaller— the same code path Skidbladnir uses.skidbladniris on crates.io (0.1.14), so an outside user gets the identical renderer, not a copy of it.- A test asserts the
ExecStartproduced by the two entry points is byte-identical. The failure mode being designed against is not "two files", it is "two files that drift".
Install semantics:
installwrites the unit and a 0600EnvironmentFile,daemon-reload,enable --now. Skidbladnir's systemd renderer already routes secrets to a 0600EnvironmentFile=and never inline in the world-readable unit (Skidbladnir/src/systemd.rs:511,904); that policy is inherited, not rewritten.install --dry-runprints the unit and the env-file keys (never values) and writes nothing.installis idempotent: same spec ⇒ no-op; changed spec ⇒ rewrite and reload.uninstallstops, disables and removes the unit. It must not delete the cursor or the config.uninstall --purgedoes, and says what it removed.
Secrets (DORIS_USER, DORIS_PASSWORD, MINIO_ACCESS_KEY, MINIO_SECRET_KEY) come from the environment only, never a flag: flags are in /proc/<pid>/cmdline, readable by any user on the host, and land in shell history and process listings. No shell anywhere in this path — the installer is Rust, per the zero-shell charter.
SIGTERM finishes the in-flight window, commits the cursor, exits 0. Readiness is every configured sink's probe() passing.
Status: f7 is design only. This document specifies the service; the code landing alongside it is the sink, not the installer.
f8 — what has to be measured before this is worth deploying
The claim is that incremental delivery beats Doris's own refresh. That is arithmetic, not a measurement. In this order:
- Partition the Iceberg table and try Doris's partitioned MV refresh. This is new to the plan and it comes first, because if it works the rest may be unnecessary for this workload. Doris 4.0.8 supports partitioned refresh over Iceberg (since 3.1) for a single partition column.
bench.doris2ghas no partition column today. Partitioning it is also what the bench doc already wants for scan pruning, so the work pays twice. - Time a
COMPLETEMV refresh on the 110 M-row Iceberg table, scale to 2 B. (Provenance warning: "110 M-row table" appears only in the earlier draft of this spec, not in the red bench doc. Confirm it exists before planning around it.) - Time
read_changelogover a real append window on the same table. - Time a Stream Load of that window into the native table.
(0) or (1) is the number to beat. (2) + (3) is what this costs. If they are within an order of magnitude, a scheduled watermark INSERT ... SELECT — or a partitioned MV — wins on operational surface and this daemon should not run. That outcome is acceptable and should be reported rather than engineered around. The sink is still worth having as a seam; running it in production is a separate decision that these measurements make.
The bencher knut.doris_stream_load measures the encode half of (2) + (3) against a fake transport, so it is tracked continuously; the network half needs the cluster.
f10 — status 2026-09-26: built, packaged, and run live
What was design above is now code on branch iceberg-doris-cdc (skade side: iceberg-s3-cdc, skade 0.5.4 / skade-aegir 0.1.3 / iceberg-catalog-rest-arrow59 0.9.7, unpublished at the time of writing).
- The driver —
delivery::IcebergDelivery: one Iceberg snapshot per window,deliver_windowper window, cursor after ack. The table is found three ways (IcebergLocator): a skade warehouse; an S3 root with no catalog (skadeconnect::S3Table:version-hint.text, else the newest*.metadata.json); a REST catalog, incl. MinIO AIStor's/_icebergsigned with SigV4 services3tables(skadeIcebergRest.sigv4). The f6 refusal runs off the snapshot operations in the metadata already loaded; an overwrite in history is the whole current snapshot on the INITIAL load only. - f7 is no longer design only —
bifrost doris run|install|uninstall|unit| start|stop|status|k8s(featureservice).installis skidbladnir's Lifecycle (systemd);k8srenders the same ServiceSpec through skidbladnir's KubernetesRenderer (StatefulSet + cursor volume + 0600 Secret). A test holds the unit and the StatefulSet to the same arguments and keeps secrets off both. Build Thing rowbifrost-doris; imagecontainers/bifrost/Containerfile. - Payload —
LoadFormat::Arrow(one Arrow IPC stream,format: arrow) beside JSON; a window's chunks load concurrently through gatling io (max_parallel_loads, default 4), acked in chunk order; bodies areBytes. -
Two facts only a live Doris gave:
Expect: 100-continuethrough reqwest/hyper IS accepted by Doris 4.1.3/4.1.4 (it was "genuinely unverified" in Testing below).- An arrow body without a
columnsheader fails when it carries fewer columns than the table — a key-only DELETE:insert_range_from … out of bound (data.size() = 0)(4.1.4). Every load now names its columns.
Measured. Rows land, count checked with a scan-forcing count(*) where id >= 0.
| rig | source | rows | format · loads in flight | rows/s |
| red (t14s over WireGuard) | AIStor REST, bench.smoke10m, 1 commit |
10 M | arrow · 1 | 157 600 |
| red | same | 10 M | arrow · 8 | 202 600 |
| red | same | 10 M | json · 1 | 78 400 |
loki rig (draupnir, Doris 4.1.4, BE on NVMe medium:ssd) |
MinIO, no catalog, 10 commits | 20 M | arrow · 1 / 4 / 8 | 810 000 / 809 000 / 824 000 |
| loki rig | same | 20 M | json · 4 | 734 000 |
Reading: on red the tunnel is the ceiling (json's larger bodies halve the rate); without it, arrow vs json is ~10 %, and loads-in-flight does nothing on the rig because a 2 M-row commit is ONE 64 MiB chunk — the time is one Stream Load per window in the BE (~1.15 s per 2 M rows on one BE). The next lever is smaller chunks or more BEs, not more client concurrency. Read (skade changelog off MinIO) is 1.2 s of 24 s.
Live tests (#[ignore], arms in .nornir/testmatrix-arms.json): tests/live_iceberg_doris.rs — green on red: AIStor REST + SigV4, a new table, 3 commits, replay refused by label, MATCH_ANY on the inverted index, and the same table off S3 with no catalog. tests/container_rig.rs — green on loki: inserts, equality deletes and updates (delete + append) off MinIO into a unique-key merge-on-write table whose partitions report medium SSD.
f9 — public surface + feature flags
| item | module | feature | default |
GraphSink, FalkorDbSink |
sink::graph |
— | on (unchanged) |
TableSink, TableWindow, LoadAck, AckToken, SnapshotWindow, deliver_window, refuse_full_reload, stream_load_label, is_valid_doris_label |
sink::table |
— | on, no new dependency |
DorisSink, DorisConfig, StreamLoadTransport, StreamLoadRequest/Response |
sink::doris |
doris |
off |
ReqwestTransport (the real HTTP client) |
sink::doris |
doris |
off |
doris = ["dep:reqwest"], and reqwest is already an optional dependency for feature airflow — enabling doris adds no crate to the workspace tree that is not already reachable. The default build pays nothing.
The transport is a trait, not a concrete client, for one reason: every correctness property in this document (label derivation, ordering, replay rejection, full-reload refusal, schema refusal, delete-mode refusal) becomes testable without a Doris cluster. Only throughput needs the real thing.
Testing
Per workspace convention every change lands with tests and benchers.
- label derivation is stable across runs and collision-free across snapshot pairs, including the
from = Noneand multi-chunk cases - the cursor is committed only after a successful apply — asserted by observing call order, because this is the data-loss bug
- replaying an identical window is a no-op: duplicate labels are rejected and the loaded row count is unchanged
needs_full_reloadrefuses and reportsok = false, rather than reloading- a schema mismatch is caught at
probe()and refuses to start, not mid-stream - a
DELETErow arriving inappendmode fails the window loudly - an FE redirect to an unreachable BE is reported as a reachability failure naming the BE and the timeouts — the measured behaviour is a hang, and the timeout is what makes it reportable
Expect: 100-continueis present on every PUT, asserted with a one-row window- every generated label matches Doris's
^[\-_A-Za-z0-9:\p{L}]{1,128}$ - a
Label Already Existsverdict inside an HTTP 200 body is read as a duplicate, not as a successful load - bencher
knut.doris_stream_load: rows/s through changelog → encode → load
Duplicate-label rejection, the label regex, the mandatory Expect header and the FE→BE hang have since been measured against red (Doris 4.0.8) and the findings are folded in above; the fake transport now encodes the measured protocol rather than the documented one.
Still not covered without a live cluster, stated rather than faked: label expiry behaviour, merge_type: DELETE semantics on a real unique-key table, and whether Expect: 100-continue survives the real HTTP stack to the wire (reqwest/hyper may handle that header specially — genuinely unverified). Those are integration work against red.
The bencher does not currently run. xtask/examples/nornir-bench.rs does not build on main: nornir 0.5.4 pulls iceberg-datafusion-arrow58 0.9.7, which fails against the resolved datafusion (unresolved import datafusion::sql::TableReference). This reproduces with and without the doris feature, so it is pre-existing and out of scope here. The knut.doris_stream_load arm is registered, and the path it measures is covered by tests/doris_sink.rs::the_bencher_path_delivers_100k_rows_and_replays_for_free so the delivery path is not left unverified while the harness is red.
See also
cdc-design.md— the change-feed seam this consumes.bifrost-design.md— the listener, sources and state.- skade's
cdc-stream-design.md— the delta primitives, which stay in skade. doris-sink-guide.md·doris-sink-for-idiots.md