Skip to main content

risingwave_meta/barrier/checkpoint/independent_job/iceberg_v3_job/
render.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{HashMap, HashSet};
16
17use risingwave_common::util::stream_graph_visitor::visit_stream_node_cont;
18use risingwave_meta_model::WorkerId;
19use risingwave_pb::id::{ActorId, FragmentId, PartialGraphId};
20use risingwave_pb::stream_plan::stream_node::NodeBody;
21use risingwave_pb::stream_plan::{PbStreamNode, UpdateMutation};
22
23use super::super::RenderedIndependentJobActors;
24use crate::MetaResult;
25use crate::barrier::edge_builder::FragmentEdgeBuilder;
26use crate::barrier::rpc::ControlStreamManager;
27use crate::controller::fragment::InflightFragmentInfo;
28use crate::model::{
29    DownstreamFragmentRelation, FragmentDownstreamRelation, StreamActor, StreamJobActorsToCreate,
30};
31
32#[derive(Debug)]
33pub(super) struct CompactionTransition {
34    pub actors_to_create: StreamJobActorsToCreate,
35    pub mutation: UpdateMutation,
36}
37
38/// Actor-level plans generated from the current graph for one compaction round.
39#[derive(Debug)]
40pub(super) struct CompactionTransitions {
41    /// Actors from the normal-input, resolver, and long-running partitions.
42    pub node_actors: HashMap<WorkerId, HashSet<ActorId>>,
43    pub begin_compaction: CompactionTransition,
44    pub end_compaction: CompactionTransition,
45}
46
47/// The resolver fragment is rendered and placed with the whole Iceberg graph, but remains
48/// disconnected until a compaction command activates it.
49#[derive(Debug)]
50pub(super) struct RenderedResolver {
51    pub fragment_info: InflightFragmentInfo,
52    /// Current dormant actor definitions. Scaling must update these together with `fragment_info`.
53    pub stream_actors: Vec<StreamActor>,
54    pub downstream_relation: DownstreamFragmentRelation,
55}
56
57/// Actor-independent fragment partitioning shared by every compaction round. Scaling may replace
58/// actors and placements, but does not change which fragments belong to each execution phase.
59#[derive(Debug)]
60pub(super) struct IcebergV3FragmentPartition {
61    normal: HashSet<FragmentId>,
62    resolver: HashSet<FragmentId>,
63    long_running: HashSet<FragmentId>,
64}
65
66pub(super) fn build_compaction_transitions(
67    active_fragment_infos: &HashMap<FragmentId, InflightFragmentInfo>,
68    active_stream_actors: &HashMap<FragmentId, Vec<StreamActor>>,
69    active_downstreams: &FragmentDownstreamRelation,
70    resolver: &RenderedResolver,
71    partition: &IcebergV3FragmentPartition,
72    partial_graph_id: PartialGraphId,
73    control_stream_manager: &ControlStreamManager,
74) -> MetaResult<CompactionTransitions> {
75    let fragment_infos: HashMap<_, _> = active_fragment_infos
76        .iter()
77        .map(|(&fragment_id, info)| (fragment_id, info))
78        .chain(std::iter::once((
79            resolver.fragment_info.fragment_id,
80            &resolver.fragment_info,
81        )))
82        .collect();
83    let stream_actors: HashMap<_, _> = active_stream_actors
84        .iter()
85        .map(|(&fragment_id, actors)| (fragment_id, actors.as_slice()))
86        .chain(std::iter::once((
87            resolver.fragment_info.fragment_id,
88            resolver.stream_actors.as_slice(),
89        )))
90        .collect();
91    let mut downstreams = active_downstreams.clone();
92    assert!(
93        downstreams
94            .insert(
95                resolver.fragment_info.fragment_id,
96                vec![resolver.downstream_relation.clone()],
97            )
98            .is_none(),
99        "resolver relation must be absent from the active graph"
100    );
101    let begin_compaction = build_transition(
102        &partition.normal,
103        &partition.resolver,
104        &partition.long_running,
105        &fragment_infos,
106        &stream_actors,
107        &downstreams,
108        partial_graph_id,
109        control_stream_manager,
110    )?;
111    let end_compaction = build_transition(
112        &partition.resolver,
113        &partition.normal,
114        &partition.long_running,
115        &fragment_infos,
116        &stream_actors,
117        &downstreams,
118        partial_graph_id,
119        control_stream_manager,
120    )?;
121    let node_actors = InflightFragmentInfo::actor_ids_to_collect(fragment_infos.values().copied());
122
123    Ok(CompactionTransitions {
124        node_actors,
125        begin_compaction,
126        end_compaction,
127    })
128}
129
130fn ensure_writer_relation(
131    downstreams: &FragmentDownstreamRelation,
132    edge_fragment_id: FragmentId,
133    writer_fragment_id: FragmentId,
134) -> MetaResult<()> {
135    if downstreams
136        .get(&edge_fragment_id)
137        .into_iter()
138        .flatten()
139        .any(|relation| relation.downstream_fragment_id == writer_fragment_id)
140    {
141        Ok(())
142    } else {
143        Err(anyhow::anyhow!(
144            "fragment {} has no edge to writer fragment {}",
145            edge_fragment_id,
146            writer_fragment_id
147        )
148        .into())
149    }
150}
151
152fn build_transition(
153    stopping_fragment_ids: &HashSet<FragmentId>,
154    starting_fragment_ids: &HashSet<FragmentId>,
155    long_running_fragment_ids: &HashSet<FragmentId>,
156    fragment_infos: &HashMap<FragmentId, &InflightFragmentInfo>,
157    stream_actors: &HashMap<FragmentId, &[StreamActor]>,
158    downstreams: &FragmentDownstreamRelation,
159    partial_graph_id: PartialGraphId,
160    control_stream_manager: &ControlStreamManager,
161) -> MetaResult<CompactionTransition> {
162    let (mut edges, _) = FragmentEdgeBuilder::new()
163        .add_existing_fragments(
164            stopping_fragment_ids
165                .iter()
166                .map(|fragment_id| fragment_infos[fragment_id])
167                .chain(
168                    long_running_fragment_ids
169                        .iter()
170                        .map(|fragment_id| fragment_infos[fragment_id]),
171                ),
172            partial_graph_id,
173            control_stream_manager,
174        )
175        .stop_existing_fragments(stopping_fragment_ids.iter().copied())
176        .add_new_fragments(
177            starting_fragment_ids
178                .iter()
179                .map(|fragment_id| fragment_infos[fragment_id]),
180            partial_graph_id,
181            control_stream_manager,
182        )
183        .finish_fragments()
184        .add_relations(downstreams)?
185        .build();
186    let actors_to_create =
187        edges.collect_actors_to_create(starting_fragment_ids.iter().map(|fragment_id| {
188            let fragment = fragment_infos[fragment_id];
189            (
190                fragment.fragment_id,
191                &fragment.nodes,
192                fragment.actors.iter().map(|(actor_id, actor)| {
193                    let stream_actor = stream_actors[&fragment.fragment_id]
194                        .iter()
195                        .find(|stream_actor| stream_actor.actor_id == *actor_id)
196                        .expect("rendered actor should exist");
197                    (stream_actor, actor.worker_id)
198                }),
199                vec![],
200            )
201        }));
202    let mut mutation = UpdateMutation {
203        dropped_actors: stopping_fragment_ids
204            .iter()
205            .flat_map(|fragment_id| fragment_infos[fragment_id].actors.keys().copied())
206            .collect(),
207        ..Default::default()
208    };
209    edges.apply_to_update_mutation(&mut mutation);
210    Ok(CompactionTransition {
211        actors_to_create,
212        mutation,
213    })
214}
215
216/// Split the dormant resolver from an Iceberg graph after actor placement and before edge
217/// construction. The complete relation map must be used during rendering so a no-shuffle
218/// resolver edge participates in actor alignment.
219pub(super) fn partition_resolver(
220    mut rendered: RenderedIndependentJobActors,
221    downstreams: &mut FragmentDownstreamRelation,
222) -> MetaResult<(
223    RenderedIndependentJobActors,
224    RenderedResolver,
225    IcebergV3FragmentPartition,
226)> {
227    // The writer's two Merge inputs identify the phase-specific entry fragments: normal
228    // snapshot/log-store input on the left and the dormant compaction resolver on the right.
229    let writer_fragment = rendered
230        .fragment_infos
231        .values()
232        .find(|fragment| find_writer_node(&fragment.nodes).is_some())
233        .ok_or_else(|| anyhow::anyhow!("Iceberg V3 graph has no pk-index writer"))?;
234    let writer_fragment_id = writer_fragment.fragment_id;
235    let writer_node = find_writer_node(&writer_fragment.nodes)
236        .expect("writer fragment was selected by its writer node");
237    let [normal_input, resolver_input] = writer_node.input.as_slice() else {
238        return Err(anyhow::anyhow!("Iceberg writer must have exactly two inputs").into());
239    };
240    let Some(NodeBody::Merge(normal_merge)) = normal_input.node_body.as_ref() else {
241        return Err(anyhow::anyhow!("Iceberg writer normal input must be a Merge node").into());
242    };
243    let Some(NodeBody::Merge(resolver_merge)) = resolver_input.node_body.as_ref() else {
244        return Err(anyhow::anyhow!("Iceberg writer resolver input must be a Merge node").into());
245    };
246    let normal_fragment_id = normal_merge.upstream_fragment_id;
247    let resolver_fragment_id = resolver_merge.upstream_fragment_id;
248
249    // The writer and every fragment transitively downstream of it stay alive while switching
250    // phases. They form the long-running partition shared by normal execution and compaction.
251    let mut long_running = HashSet::from([writer_fragment_id]);
252    let mut pending = vec![writer_fragment_id];
253    while let Some(fragment_id) = pending.pop() {
254        for downstream in downstreams.get(&fragment_id).into_iter().flatten() {
255            if long_running.insert(downstream.downstream_fragment_id) {
256                pending.push(downstream.downstream_fragment_id);
257            }
258        }
259    }
260    // All remaining rendered fragments belong to normal input, except for the resolver fragment
261    // that is activated only during compaction.
262    let normal: HashSet<_> = rendered
263        .fragment_infos
264        .keys()
265        .filter(|fragment_id| {
266            !long_running.contains(*fragment_id) && **fragment_id != resolver_fragment_id
267        })
268        .copied()
269        .collect();
270    if !normal.contains(&normal_fragment_id) {
271        return Err(anyhow::anyhow!(
272            "normal input fragment {} was classified as long-running",
273            normal_fragment_id
274        )
275        .into());
276    }
277    // Both phase-specific entry fragments must feed the writer directly. The partition is static
278    // across compaction rounds even though scaling may replace its actors and placements.
279    ensure_writer_relation(downstreams, normal_fragment_id, writer_fragment_id)?;
280    ensure_writer_relation(downstreams, resolver_fragment_id, writer_fragment_id)?;
281    let partition = IcebergV3FragmentPartition {
282        normal,
283        resolver: HashSet::from([resolver_fragment_id]),
284        long_running,
285    };
286
287    // Actor placement used the complete relation map above. Detach the resolver only afterwards so
288    // the initial active graph contains normal input while retaining the dormant actors and edge.
289    let resolver_fragment = rendered
290        .fragment_infos
291        .remove(&resolver_fragment_id)
292        .ok_or_else(|| {
293            anyhow::anyhow!(
294                "Iceberg writer resolver fragment {} was not rendered",
295                resolver_fragment_id
296            )
297        })?;
298    if !contains_resolver(&resolver_fragment.nodes) {
299        return Err(anyhow::anyhow!(
300            "Iceberg writer resolver input fragment {} has no resolver node",
301            resolver_fragment_id
302        )
303        .into());
304    }
305    let resolver_actors = rendered
306        .stream_actors
307        .remove(&resolver_fragment_id)
308        .ok_or_else(|| {
309            anyhow::anyhow!(
310                "Iceberg writer resolver fragment {} has no rendered actors",
311                resolver_fragment_id
312            )
313        })?;
314
315    let downstream_relation = {
316        let relations = downstreams.get_mut(&resolver_fragment_id).ok_or_else(|| {
317            anyhow::anyhow!(
318                "resolver fragment {} has no downstream relation",
319                resolver_fragment_id
320            )
321        })?;
322        let [relation] = relations.as_slice() else {
323            return Err(anyhow::anyhow!(
324                "expected resolver fragment {} to have exactly one downstream relation, found {}",
325                resolver_fragment_id,
326                relations.len(),
327            )
328            .into());
329        };
330        if relation.downstream_fragment_id != writer_fragment_id {
331            return Err(anyhow::anyhow!(
332                "resolver fragment {} points to fragment {} instead of writer fragment {}",
333                resolver_fragment_id,
334                relation.downstream_fragment_id,
335                writer_fragment_id,
336            )
337            .into());
338        }
339        relations.remove(0)
340    };
341    downstreams.remove(&resolver_fragment_id);
342    if downstreams
343        .values()
344        .flatten()
345        .any(|relation| relation.downstream_fragment_id == resolver_fragment_id)
346    {
347        return Err(anyhow::anyhow!(
348            "resolver fragment {} must not have an upstream fragment",
349            resolver_fragment_id
350        )
351        .into());
352    }
353
354    Ok((
355        rendered,
356        RenderedResolver {
357            fragment_info: resolver_fragment,
358            stream_actors: resolver_actors,
359            downstream_relation,
360        },
361        partition,
362    ))
363}
364
365fn find_writer_node(node: &PbStreamNode) -> Option<&PbStreamNode> {
366    let mut found = None;
367    visit_stream_node_cont(node, |node| {
368        if found.is_none() && matches!(node.node_body, Some(NodeBody::IcebergWithPkIndexWriter(_)))
369        {
370            found = Some(node);
371        }
372        found.is_none()
373    });
374    found
375}
376
377fn contains_resolver(node: &PbStreamNode) -> bool {
378    let mut found = false;
379    visit_stream_node_cont(node, |node| {
380        if matches!(node.node_body, Some(NodeBody::CompactionResolver(_))) {
381            found = true;
382        }
383        !found
384    });
385    found
386}