Skip to main content

risingwave_stream/executor/iceberg_with_pk_index/
compaction_resolver.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 anyhow::{Context, anyhow};
16use hashbrown::{HashMap, HashSet};
17use iceberg::delete_vector::DeleteVector;
18use iceberg::io::FileIO;
19use iceberg::spec::{DataContentType, ManifestContentType, SnapshotRef};
20use iceberg::table::Table;
21use iceberg::writer::file_writer::location_generator::DefaultLocationGenerator;
22use parquet::arrow::{ParquetRecordBatchStreamBuilder, ProjectionMask};
23use parquet::schema::types::SchemaDescriptor;
24use risingwave_common::array::DataChunk;
25use risingwave_common::array::arrow::IcebergArrowConvert;
26use risingwave_common::id::SinkId;
27use risingwave_common::row::RowExt;
28use risingwave_common::util::chunk_coalesce::DataChunkBuilder;
29use risingwave_connector::sink::SinkError;
30use risingwave_connector::sink::iceberg::{
31    IcebergConfig, IcebergPositionDeleteCommitResult, PkIndexDataFileLocator,
32    PositionDeleteFileNameGenerators, read_position_deletes_from_file,
33    serialize_data_files_default_spec, write_position_delete_file,
34};
35use risingwave_connector::source::iceberg::ParquetFileReader;
36use risingwave_pb::connector_service::SinkMetadata;
37use risingwave_pb::id::IcebergCompactionTaskId;
38use risingwave_pb::stream_plan::iceberg_pk_index_compaction_context::{Phase, ResolverTaskInput};
39use risingwave_pb::stream_service::PbIcebergPkIndexSinkRole;
40use risingwave_rpc_client::MetaClient;
41use tokio::sync::mpsc::UnboundedReceiver;
42use uuid::Uuid;
43
44use super::load_table_at_least;
45use crate::executor::prelude::*;
46use crate::task::LocalBarrierManager;
47
48/// Leaf executor for the iceberg pk-index coordinated compaction.
49///
50/// It has **no stream input**. The actor is part of the sink's static graph but only runs while a
51/// compaction is being applied. It is driven by barriers delivered directly through
52/// `barrier_receiver` and produces resolved survivor rows for the downstream writer.
53///
54/// Lifecycle:
55/// - On the first barrier: forward it (no state to init).
56/// - After resolve completes, on the next barrier (the end barrier): REPORT the conflict DV
57///   files to meta, forward the barrier, and exit.
58///
59/// # Output chunk contract
60///
61/// Schema: `[pk_columns.., data_file, position: Int64]`, where `data_file` is the output file
62/// path (Varchar) in the legacy layout, or its uuid (Bytea) otherwise, matching the writer's PK
63/// index. Every emitted row is a SURVIVOR: `Writer` upserts `pk -> (data_file, position)` in the
64/// index (index-only; the data already lives in the compaction output file). Conflicts (rows
65/// killed during the window) are NOT emitted here.
66pub struct CompactionResolverExecutor {
67    ctx: ActorContextRef,
68    sink_id: SinkId,
69    iceberg_config: IcebergConfig,
70    pk_indices: Vec<usize>,
71    pk_data_types: Vec<DataType>,
72    chunk_size: usize,
73    local_barrier_manager: LocalBarrierManager,
74    barrier_receiver: UnboundedReceiver<Barrier>,
75    meta_client: MetaClient,
76}
77
78fn resolver_task_from_initial_barrier(
79    sink_id: SinkId,
80    barrier: &Barrier,
81) -> StreamExecutorResult<(IcebergCompactionTaskId, ResolverTaskInput)> {
82    match barrier.iceberg_pk_index_compaction() {
83        Some(context)
84            if barrier.is_checkpoint()
85                && context.sink_id == sink_id
86                && context.phase == Phase::Begin as i32 =>
87        {
88            let task_input = context.resolver_task_input.clone().ok_or_else(|| {
89                StreamExecutorError::from(anyhow!(
90                    "compaction resolver sink {} task {} missing resolver task input",
91                    sink_id,
92                    context.task_id
93                ))
94            })?;
95            Ok((context.task_id, task_input))
96        }
97        _ => Err(StreamExecutorError::from(anyhow!(
98            "compaction resolver sink {} expected initial begin barrier, got {:?}",
99            sink_id,
100            barrier
101        ))),
102    }
103}
104
105fn validate_resolver_end_barrier(
106    sink_id: SinkId,
107    barrier: &Barrier,
108    begin: &Barrier,
109    task_id: IcebergCompactionTaskId,
110) -> StreamExecutorResult<()> {
111    match barrier.iceberg_pk_index_compaction() {
112        Some(context)
113            if barrier.is_checkpoint()
114                && barrier.epoch.prev == begin.epoch.curr
115                && context.sink_id == sink_id
116                && context.task_id == task_id
117                && context.phase == Phase::End as i32
118                && context.resolver_task_input.is_none() =>
119        {
120            Ok(())
121        }
122        _ => Err(StreamExecutorError::from(anyhow!(
123            "compaction resolver sink {} task {} expected end barrier, got {:?}",
124            sink_id,
125            task_id,
126            barrier
127        ))),
128    }
129}
130
131impl CompactionResolverExecutor {
132    #[expect(clippy::too_many_arguments)]
133    pub fn new(
134        ctx: ActorContextRef,
135        sink_id: SinkId,
136        iceberg_config: IcebergConfig,
137        pk_indices: Vec<usize>,
138        pk_data_types: Vec<DataType>,
139        chunk_size: usize,
140        local_barrier_manager: LocalBarrierManager,
141        barrier_receiver: UnboundedReceiver<Barrier>,
142        meta_client: MetaClient,
143    ) -> Self {
144        Self {
145            ctx,
146            sink_id,
147            iceberg_config,
148            pk_indices,
149            pk_data_types,
150            chunk_size,
151            local_barrier_manager,
152            barrier_receiver,
153            meta_client,
154        }
155    }
156
157    /// Data types of the survivor output chunk: `[pk_columns.., data_file, position]`.
158    fn output_data_types(&self) -> Vec<DataType> {
159        let mut types = self.pk_data_types.clone();
160        types.push(if self.iceberg_config.pk_index_stores_file_uuid() {
161            DataType::Bytea
162        } else {
163            DataType::Varchar
164        });
165        types.push(DataType::Int64);
166        types
167    }
168
169    #[try_stream(ok = DataChunk, error = SinkError)]
170    async fn resolve<'a>(
171        &'a self,
172        task_input: &'a ResolverTaskInput,
173        expected_snapshot: Option<i64>,
174        conflict_delete_metadata: &'a mut Option<SinkMetadata>,
175    ) {
176        let table = load_table_at_least(&self.iceberg_config, expected_snapshot).await?;
177        let file_io = table.file_io();
178        let snapshot_r = table
179            .metadata()
180            .snapshot_by_id(task_input.read_snapshot_id)
181            .with_context(|| {
182                format!(
183                    "compaction read snapshot {} not present in table metadata",
184                    task_input.read_snapshot_id
185                )
186            })?;
187        let snapshot_n = table
188            .metadata()
189            .current_snapshot()
190            .context("table has no current snapshot to resolve compaction against")?;
191
192        let input_paths: HashSet<&str> = task_input
193            .input_data_file_paths
194            .iter()
195            .map(String::as_str)
196            .collect();
197
198        // Deletion-vector positions per input data file, at R (baseline) and at N (post-window).
199        let dv_r = collect_input_dvs(&table, snapshot_r, &input_paths).await?;
200        let dv_n = collect_input_dvs(&table, snapshot_n, &input_paths).await?;
201
202        // Selectively re-read the input data files at the delete-diff positions to recover the primary
203        // keys deleted during the window. `pk` datums are keyed by an `OwnedRow` so membership tests
204        // against the output scan below are exact.
205        let mut delete_diff_pk_set: HashSet<OwnedRow> = HashSet::new();
206        let stream = futures::stream::iter(task_input.input_data_file_paths.iter().map(
207            |input_path| async {
208                let Some(n_positions) = dv_n.get(input_path) else {
209                    return Ok(Vec::new());
210                };
211                let r_positions = dv_r.get(input_path);
212                let mut diff = n_positions.clone();
213                if let Some(r_positions) = r_positions {
214                    diff -= r_positions;
215                }
216                if diff.is_empty() {
217                    return Ok(Vec::new());
218                }
219                scan_input_pks_at_positions(file_io, input_path, &self.pk_indices, &diff).await
220            },
221        ))
222        .buffer_unordered(8);
223        #[for_await]
224        for pks in stream {
225            delete_diff_pk_set.extend(pks?);
226        }
227
228        // Scan every output data file once per actor.
229        let data_file_locator = if self.iceberg_config.pk_index_stores_file_uuid() {
230            Some(PkIndexDataFileLocator::new(table.metadata())?)
231        } else {
232            None
233        };
234        let mut conflicts: HashMap<String, DeleteVector> = HashMap::new();
235        #[for_await]
236        for chunk in self.scan_output_file(
237            file_io,
238            data_file_locator.as_ref(),
239            &task_input.output_data_file_paths,
240            &delete_diff_pk_set,
241            &mut conflicts,
242        ) {
243            yield chunk?;
244        }
245
246        *conflict_delete_metadata =
247            write_conflict_delete_files(&self.iceberg_config, &table, conflicts).await?;
248    }
249
250    #[try_stream(ok = DataChunk, error = SinkError)]
251    async fn scan_output_file<'a>(
252        &'a self,
253        file_io: &'a FileIO,
254        data_file_locator: Option<&'a PkIndexDataFileLocator>,
255        output_data_file_paths: &'a [String],
256        delete_diff_pk_set: &'a HashSet<OwnedRow>,
257        conflicts: &'a mut HashMap<String, DeleteVector>,
258    ) {
259        let mut buffer = DataChunkBuilder::new(self.output_data_types(), self.chunk_size);
260        for file in output_data_file_paths {
261            let file_uuid = data_file_locator
262                .map(|locator| locator.file_uuid(file))
263                .transpose()?;
264            let data_file = match &file_uuid {
265                None => ScalarRefImpl::Utf8(file),
266                Some(uuid) => ScalarRefImpl::Bytea(uuid.as_bytes()),
267            };
268            #[for_await]
269            for chunk in scan_output_file_inner(
270                file_io,
271                file,
272                data_file,
273                &self.pk_indices,
274                delete_diff_pk_set,
275                &mut buffer,
276                conflicts,
277            ) {
278                yield chunk?;
279            }
280        }
281        if let Some(chunk) = buffer.consume_all() {
282            yield chunk;
283        }
284    }
285
286    #[try_stream(ok = Message, error = StreamExecutorError)]
287    async fn execute_inner(mut self) {
288        let actor_id = self.ctx.id;
289
290        // First barrier: forward it. There is no state table to initialize.
291        let first_barrier = self.barrier_receiver.recv().await.ok_or_else(|| {
292            StreamExecutorError::channel_closed("compaction resolver barrier receiver")
293        })?;
294        let (task_id, task_input) =
295            resolver_task_from_initial_barrier(self.sink_id, &first_barrier)?;
296        let begin_barrier = first_barrier.clone();
297        yield Message::Barrier(first_barrier);
298
299        let expected_snapshot = self
300            .meta_client
301            .wait_iceberg_pk_index_sink_epoch(self.sink_id, begin_barrier.epoch.prev)
302            .await?;
303
304        let mut conflict_delete_metadata = None;
305        #[for_await]
306        for chunk in self.resolve(
307            &task_input,
308            expected_snapshot,
309            &mut conflict_delete_metadata,
310        ) {
311            let chunk = chunk.map_err(|e| (e, self.sink_id))?;
312            yield Message::Chunk(chunk.into());
313        }
314
315        let Some(barrier) = self.barrier_receiver.recv().await else {
316            return Err(StreamExecutorError::channel_closed(
317                "compaction resolver barrier receiver",
318            ));
319        };
320        validate_resolver_end_barrier(self.sink_id, &barrier, &begin_barrier, task_id)?;
321        if let Some(metadata) = conflict_delete_metadata
322            && metadata.metadata.is_some()
323        {
324            self.local_barrier_manager
325                .report_iceberg_pk_index_sink_metadata(
326                    barrier.epoch,
327                    self.sink_id,
328                    actor_id,
329                    PbIcebergPkIndexSinkRole::CompactionResolver,
330                    Some(metadata),
331                );
332        }
333        yield Message::Barrier(barrier);
334    }
335}
336
337impl Execute for CompactionResolverExecutor {
338    fn execute(self: Box<Self>) -> BoxedMessageStream {
339        self.execute_inner().boxed()
340    }
341}
342
343/// Walks the manifests of `snapshot` and collects, per input data file, the merged set of deleted
344/// positions from every live position-delete file referencing it.
345async fn collect_input_dvs(
346    table: &Table,
347    snapshot: &SnapshotRef,
348    input_paths: &HashSet<&str>,
349) -> Result<HashMap<String, DeleteVector>, SinkError> {
350    let file_io = table.file_io();
351    let mut map: HashMap<String, DeleteVector> = HashMap::new();
352
353    let manifest_list = table
354        .object_cache()
355        .get_manifest_list(snapshot, &table.metadata_ref())
356        .await?;
357
358    for manifest_file in manifest_list.entries() {
359        // Only delete manifests can carry position-delete files.
360        if manifest_file.content != ManifestContentType::Deletes {
361            continue;
362        }
363        let manifest = manifest_file.load_manifest(file_io).await?;
364        for entry in manifest.entries() {
365            if !entry.is_alive() {
366                continue;
367            }
368            let data_file = entry.data_file();
369            if data_file.content_type() == DataContentType::PositionDeletes
370                && let Some(referenced) = data_file.referenced_data_file()
371                && input_paths.contains(referenced.as_str())
372            {
373                let positions = read_position_deletes_from_file(file_io, data_file)
374                    .await
375                    .map_err(SinkError::Iceberg)?;
376                if map.insert(referenced, positions).is_some() {
377                    return Err(SinkError::Iceberg(anyhow!(
378                        "input data file {} has multiple live position-delete files in snapshot {}",
379                        data_file.referenced_data_file().unwrap(),
380                        snapshot.snapshot_id()
381                    )));
382                }
383            }
384        }
385    }
386    Ok(map)
387}
388
389/// Builds a projection for PK root columns and maps the projected physical order back to the
390/// downstream PK order.
391fn pk_projection(
392    parquet_schema: &SchemaDescriptor,
393    pk_indices: &[usize],
394) -> Result<(ProjectionMask, Vec<usize>), SinkError> {
395    let root_count = parquet_schema.root_schema().get_fields().len();
396    let mut physical_indices = Vec::with_capacity(pk_indices.len());
397    let mut seen = HashSet::with_capacity(pk_indices.len());
398
399    for &pk_index in pk_indices {
400        if pk_index >= root_count {
401            return Err(SinkError::Iceberg(anyhow!(
402                "pk index {pk_index} is out of range for parquet schema with {root_count} root columns"
403            )));
404        }
405        if !seen.insert(pk_index) {
406            return Err(SinkError::Iceberg(anyhow!("duplicate pk index {pk_index}")));
407        }
408        physical_indices.push(pk_index);
409    }
410
411    physical_indices.sort_unstable();
412    let pk_order = pk_indices
413        .iter()
414        .map(|pk_index| {
415            physical_indices
416                .binary_search(pk_index)
417                .expect("validated pk index must be projected")
418        })
419        .collect();
420    let projection = ProjectionMask::roots(parquet_schema, physical_indices);
421
422    Ok((projection, pk_order))
423}
424
425async fn scan_input_pks_at_positions(
426    file_io: &FileIO,
427    path: &str,
428    pk_indices: &[usize],
429    want_positions: &DeleteVector,
430) -> Result<Vec<OwnedRow>, SinkError> {
431    let mut results = Vec::new();
432    let input_file = file_io.new_input(path)?;
433    let metadata = input_file.metadata().await?;
434    let reader = input_file.reader().await?;
435    let builder = ParquetRecordBatchStreamBuilder::new(ParquetFileReader::new(metadata, reader))
436        .await
437        .map_err(|e| anyhow!(e).context(format!("open parquet reader for {path}")))?;
438    let (projection, pk_order) = pk_projection(builder.parquet_schema(), pk_indices)?;
439    let mut stream = builder
440        .with_projection(projection)
441        .build()
442        .map_err(|e| anyhow!(e).context(format!("build parquet stream for {path}")))?;
443
444    let mut base_pos = 0;
445    let mut iter = want_positions.iter().peekable();
446    while let Some(batch) = stream.next().await {
447        let batch =
448            batch.map_err(|e| anyhow!(e).context(format!("read parquet batch of {path}")))?;
449        let chunk = IcebergArrowConvert
450            .chunk_from_record_batch(&batch)
451            .map_err(|e| anyhow!(e).context(format!("convert parquet batch of {path}")))?
452            .project(&pk_order);
453        while let Some(&iter_pos) = iter.peek() {
454            let chunk_pos = iter_pos as usize - base_pos;
455            if chunk_pos >= chunk.capacity() {
456                break;
457            }
458            let pk = chunk.row_at(chunk_pos).0.to_owned_row();
459            results.push(pk);
460            iter.next();
461        }
462        base_pos += chunk.capacity();
463    }
464
465    Ok(results)
466}
467
468#[try_stream(ok = DataChunk, error = SinkError)]
469async fn scan_output_file_inner<'a>(
470    file_io: &'a FileIO,
471    path: &'a str,
472    data_file: ScalarRefImpl<'a>,
473    pk_indices: &'a [usize],
474    delete_diff_pk_set: &'a HashSet<OwnedRow>,
475    buffer: &'a mut DataChunkBuilder,
476    conflicts: &'a mut HashMap<String, DeleteVector>,
477) {
478    let input_file = file_io.new_input(path)?;
479    let metadata = input_file.metadata().await?;
480    let reader = input_file.reader().await?;
481    let builder = ParquetRecordBatchStreamBuilder::new(ParquetFileReader::new(metadata, reader))
482        .await
483        .map_err(|e| anyhow!(e).context(format!("open parquet reader for {path}")))?;
484    let (projection, pk_order) = pk_projection(builder.parquet_schema(), pk_indices)?;
485    let stream = builder
486        .with_projection(projection)
487        .build()
488        .map_err(|e| anyhow!(e).context(format!("build parquet stream for {path}")))?;
489
490    let mut position: i64 = 0;
491
492    #[for_await]
493    for batch in stream {
494        let batch =
495            batch.map_err(|e| anyhow!(e).context(format!("read parquet batch of {path}")))?;
496        let chunk = IcebergArrowConvert
497            .chunk_from_record_batch(&batch)
498            .map_err(|e| anyhow!(e).context(format!("convert parquet batch of {path}")))?
499            .project(&pk_order);
500        for row_idx in 0..chunk.capacity() {
501            let pk = chunk.row_at(row_idx).0.into_owned_row();
502            let row_position = position + row_idx as i64;
503            if delete_diff_pk_set.contains(&pk) {
504                conflicts
505                    .entry_ref(path)
506                    .or_default()
507                    .insert(row_position as u64);
508            } else if let Some(full_chunk) = buffer.append_one_row(
509                pk.chain([Some(data_file), Some(ScalarRefImpl::Int64(row_position))]),
510            ) {
511                yield full_chunk;
512            }
513        }
514        position += chunk.capacity() as i64;
515    }
516}
517
518async fn write_conflict_delete_files(
519    config: &IcebergConfig,
520    table: &Table,
521    conflicts: HashMap<String, DeleteVector>,
522) -> Result<Option<SinkMetadata>, SinkError> {
523    if conflicts.is_empty() {
524        return Ok(None);
525    }
526
527    let location_generator = DefaultLocationGenerator::new(table.metadata())?;
528    let uuid_suffix = Uuid::now_v7();
529    let file_name_generators = PositionDeleteFileNameGenerators::new(uuid_suffix);
530    let format_version = table.metadata().format_version();
531
532    let mut delete_files = Vec::with_capacity(conflicts.len());
533    for (output_file, delete_vector) in conflicts {
534        // Compaction outputs are not committed yet and the resolver only receives their paths,
535        // so these delete files intentionally omit `PartitionKey`. Meta backfills each partition
536        // from the referenced compactor output file during the compaction pre-commit.
537        let file = write_position_delete_file(
538            table,
539            config,
540            &location_generator,
541            &file_name_generators,
542            format_version,
543            output_file,
544            &delete_vector,
545            None,
546        )
547        .await?;
548        delete_files.push(file);
549    }
550
551    let delete_files = serialize_data_files_default_spec(table, delete_files)?;
552    let result = IcebergPositionDeleteCommitResult {
553        schema_id: table.metadata().current_schema_id(),
554        partition_spec_id: table.metadata().default_partition_spec_id(),
555        delete_files,
556        // The output files are uncommitted, so no pre-existing DV file is being overwritten.
557        overwrite_files: vec![],
558    };
559    let sink_metadata = SinkMetadata::try_from(&result)?;
560    Ok(Some(sink_metadata))
561}
562
563#[cfg(test)]
564mod tests {
565    use std::sync::Arc;
566
567    use parquet::basic::Type as PhysicalType;
568    use parquet::schema::types::{SchemaDescriptor, Type};
569    use risingwave_common::array::DataChunkTestExt;
570    use risingwave_common::util::epoch::test_epoch;
571    use risingwave_pb::stream_plan::IcebergPkIndexCompactionContext;
572
573    use super::*;
574
575    fn test_parquet_schema() -> SchemaDescriptor {
576        SchemaDescriptor::new(Arc::new(
577            Type::group_type_builder("schema")
578                .with_fields(
579                    ["column_0", "column_1", "column_2", "column_3", "column_4"]
580                        .into_iter()
581                        .map(|name| {
582                            Arc::new(
583                                Type::primitive_type_builder(name, PhysicalType::INT32)
584                                    .build()
585                                    .unwrap(),
586                            )
587                        })
588                        .collect(),
589                )
590                .build()
591                .unwrap(),
592        ))
593    }
594
595    #[test]
596    fn test_pk_projection_restores_downstream_pk_order() {
597        let parquet_schema = test_parquet_schema();
598
599        let (_, order) = pk_projection(&parquet_schema, &[2, 0, 1]).unwrap();
600        assert_eq!(order, vec![2, 0, 1]);
601
602        let (_, order) = pk_projection(&parquet_schema, &[4, 1, 3]).unwrap();
603        assert_eq!(order, vec![2, 0, 1]);
604    }
605
606    #[test]
607    fn test_pk_projection_reorders_heterogeneous_chunk() {
608        let chunk = DataChunk::from_pretty(
609            "i T
610             7 k",
611        );
612        let projected = chunk.project(&[1, 0]);
613
614        assert_eq!(
615            projected,
616            DataChunk::from_pretty(
617                "T i
618                 k 7",
619            )
620        );
621    }
622
623    fn compaction_barrier(
624        epoch: u64,
625        phase: Phase,
626        resolver_task_input: Option<ResolverTaskInput>,
627    ) -> Barrier {
628        Barrier::new_test_barrier(test_epoch(epoch)).with_iceberg_pk_index_compaction(
629            IcebergPkIndexCompactionContext {
630                sink_id: SinkId::new(7),
631                task_id: 11.into(),
632                phase: phase as i32,
633                resolver_task_input,
634            },
635        )
636    }
637
638    #[test]
639    fn test_resolver_task_is_initialized_from_b1() {
640        let task_input = ResolverTaskInput {
641            output_data_file_paths: vec!["output.parquet".into()],
642            input_data_file_paths: vec!["input.parquet".into()],
643            read_snapshot_id: 42,
644        };
645        let barrier = compaction_barrier(2, Phase::Begin, Some(task_input.clone()));
646
647        let (task_id, actual) =
648            resolver_task_from_initial_barrier(SinkId::new(7), &barrier).unwrap();
649        assert_eq!(task_id, IcebergCompactionTaskId::from(11));
650        assert_eq!(actual, task_input);
651    }
652
653    #[test]
654    fn test_resolver_requires_task_input_only_on_b1() {
655        let begin = compaction_barrier(2, Phase::Begin, Some(ResolverTaskInput::default()));
656        let end = compaction_barrier(3, Phase::End, None);
657        validate_resolver_end_barrier(SinkId::new(7), &end, &begin, 11.into()).unwrap();
658
659        let invalid_begin = compaction_barrier(2, Phase::Begin, None);
660        assert!(resolver_task_from_initial_barrier(SinkId::new(7), &invalid_begin).is_err());
661        let invalid_end = compaction_barrier(3, Phase::End, Some(ResolverTaskInput::default()));
662        assert!(
663            validate_resolver_end_barrier(SinkId::new(7), &invalid_end, &begin, 11.into()).is_err()
664        );
665    }
666}