Skip to content

storage: write the persist source's decode straight into columns - #38780

Merged
antiguru merged 3 commits into
columnar-tg-temporal-bucket-nativefrom
columnar-ti-persist-source-split
Sep 12, 2026
Merged

storage: write the persist source's decode straight into columns#38780
antiguru merged 3 commits into
columnar-tg-temporal-bucket-nativefrom
columnar-ti-persist-source-split

Conversation

@antiguru

@antiguru antiguru commented Sep 11, 2026

Copy link
Copy Markdown
Member

decode_and_mfp wrote both sides of a persist read into one container as Results, so every
consumer of persist_source ran an ok_err demux, and a consumer that wanted columns paid a
second pass to re-encode the ok side. The decode operator gets a second output instead, with the
ok side generic over a container builder. The compute import asks for columns and gets them
straight from the decode; the storage sink and the materialized-view read-back ask for row
vectors and get the containers they had before, without the demux.

There is one decode implementation. record_time picks what a record stores for its time.
Compute passes |time| time.0, dropping the Subtime coordinate: it refines the capabilities so
flow control can pace parts within a millisecond, it stays on the capabilities either way, and no
reader of those streams distinguishes times that finely. persist_source_core passes
|time| time and keeps it, because upsert builds its collection inside the refined scope, then
joins the two sides back with map and concat. That is a move per record on a path that had
none, and it is the reason there is one decode operator rather than two.

Three commits, separable if you would rather review them apart:

  • txn-wal: txns_progress_remap and txns_progress_frontiers are exposed separately, so the
    two streams share one subscription to the txns shard. txns_progress_frontiers becomes generic
    over the container it passes through.
  • timely-util: an Enter impl for Column<(D, T, R)>, which a columnar collection needs to
    enter the iterative scope of a WITH MUTUALLY RECURSIVE dataflow.
  • storage-operators and compute: the split itself.

On the table-import dataflow this removes the OkErr and VecToColumnar operators and four
Vec<(Result<Row, E>, (Timestamp, Subtime), Diff)> channels, visible in the change to
test/sqllogictest/introspection/relations.slt.

Not measured. The map/concat on the upsert rehydration read is the one place this adds work
rather than removing it, and delay_sources_past_rehydration exists because that path is
latency-sensitive, so it is worth a look.

🤖 Generated with Claude Code

@antiguru
antiguru added this pull request to stack #37750 September 11, 2026 09:57
@antiguru
antiguru force-pushed the columnar-ti-persist-source-split branch from ec9fd9d to 6ddfa8b Compare September 11, 2026 11:00
@linear-code

linear-code Bot commented Sep 11, 2026

Copy link
Copy Markdown

CPU-252

@antiguru
antiguru force-pushed the columnar-ti-persist-source-split branch 2 times, most recently from 72aadae to 4056cf5 Compare September 11, 2026 11:33
Comment thread src/timely-util/src/columnar.rs Outdated
Comment on lines +221 to +224
for (d, t, r) in self.borrow().into_index_iter() {
let t = T2::to_inner(T1::into_owned(t));
inner.push((d, &t, r));
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go column-by-column, avoiding the reconstruction of the unchanged D and R columns.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. A Column::Typed now moves its data and diff columns across whole and rebuilds only the times. A serialized column owns no typed sub-containers, so all three are rebuilt there, but one column at a time rather than record by record.

Worth noting which variant this path actually sees: the compute import's columns come from ConsolidatingColumnBuilder, whose flush_aligned mints Column::Align, so the serialized arm is the common one and the move only applies to the finish() case.

Posted by Claude Code.

@antiguru
antiguru force-pushed the columnar-ti-persist-source-split branch from 4056cf5 to 3c5e76a Compare September 11, 2026 12:52
// A serialized column owns no typed sub-containers, so all three are rebuilt,
// one column at a time rather than record by record.
serialized => {
let (borrowed_data, borrowed_times, borrowed_diffs) = serialized.borrow();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just return serialized here and just pay the cost of materializing the time column

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, that works and it is better than what I had. The serialized arm now materializes only the time column and hands the data and diff columns to the output as borrowed views:

let (borrowed_data, borrowed_times, borrowed_diffs) = serialized.borrow();
let mut times = T2::Container::default();
for time in borrowed_times.into_index_iter() {
    times.push(&T2::to_inner(T1::into_owned(time)));
}
let view = (borrowed_data, times.borrow(), borrowed_diffs);
let words = indexed::length_in_words(&view);
let mut alloc: Vec<u64> = Vec::with_capacity(words);
indexed::encode(&mut alloc, &view);
Column::Align(alloc)

The result is Column::Align rather than Column::Typed, and data and diff cost one copy per column into the output allocation instead of a decode-and-re-encode per record. <(D, T2, R)>::Borrowed is just the tuple of the three borrowed sub-containers, so the mixed view encodes without any extra bounds.

That matters for this path in particular: the compute import's columns come from ConsolidatingColumnBuilder::flush_aligned, so Align is the variant that actually arrives here, and the Typed arm is the rarer finish() case. I kept the Typed arm because there data and diff move across with no copy at all.

Posted by Claude Code.

@antiguru
antiguru force-pushed the columnar-ti-persist-source-split branch 2 times, most recently from fa2d5d7 to c4a3685 Compare September 11, 2026 17:57
@antiguru
antiguru marked this pull request as ready for review September 11, 2026 17:57
@antiguru
antiguru requested review from a team and aljoscha as code owners September 11, 2026 17:57
stack merge was automatically disabled September 11, 2026 18:46

Pull Request is not mergeable

stack merge was automatically disabled September 11, 2026 18:51

Pull Request is not mergeable

@antiguru
antiguru requested a review from a team as a code owner September 11, 2026 18:53
@antiguru
antiguru force-pushed the columnar-ti-persist-source-split branch 2 times, most recently from 3dbb08c to d6f0b6c Compare September 11, 2026 19:37
@antiguru
antiguru force-pushed the columnar-ti-persist-source-split branch from d6f0b6c to 6733dce Compare September 11, 2026 20:31
@antiguru
antiguru force-pushed the columnar-ti-persist-source-split branch from 6733dce to b1e8641 Compare September 11, 2026 21:16
antiguru and others added 3 commits September 11, 2026 17:47
Translating a data shard's physical frontier into its logical one has two parts:
a subscription to the txns shard, and a passthrough operator that delays a
stream's capability by what the subscription reports. Only the passthrough is
per-stream, so `TxnsProgress::new` renders the subscription and
`TxnsProgress::translate` renders one passthrough operator per stream. Several
streams read off one data shard then share a single subscription rather than
opening one each.

The container type sits on `translate` rather than on the subscription, so one
subscription serves streams of different shapes. `txns_progress` keeps its
signature as the single-stream wrapper over both halves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Collection::enter` needs a container-level `Enter` impl to rewrite record times
into the inner timestamp. Provide one for `Column<(D, T, R)>`. Only the times
change, so a typed column moves its data and diff columns across whole, and a
serialized column materializes the times alone and encodes the other two from
their borrowed views into the output allocation. Neither path decodes and
re-encodes a record.

Without this a columnar collection cannot enter the iterative scope of a
`WITH MUTUALLY RECURSIVE` dataflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`decode_and_mfp` wrote both sides of a persist read into one container as
`Result`s, so every consumer of `persist_source` ran an `ok_err` demux, and a
consumer that wanted columns paid a second pass to re-encode the ok side. The
decode operator gets a second output instead, with the ok side generic over a
container builder, and the compute import asks for columns directly. The storage
sink and the materialized-view read-back ask for row vectors and get the
containers they had before, without the demux.

`record_time` picks what a record stores for its time. Compute passes
`|time| time.0`, dropping the `Subtime` coordinate: it refines the capabilities so
flow control can pace parts within a millisecond, it stays on the capabilities
either way, and no reader of those streams distinguishes times that finely.
`persist_source_core` passes `|time| time` and keeps it, because `upsert` builds
its collection inside the refined scope, then joins the two sides back with `map`
and `concat`. That costs a move per record on a path that had none, and buys one
decode implementation rather than two.

The two streams share one subscription to the txns shard: `TxnsProgress::new`
renders it once and `translate` renders a passthrough operator per stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru
antiguru force-pushed the columnar-ti-persist-source-split branch from b1e8641 to 03fde80 Compare September 11, 2026 21:47
@antiguru
antiguru merged commit 0a83b72 into main Sep 12, 2026
83 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants