Skip to main content

mz_persist_client/internal/
compact.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::BTreeSet;
11use std::fmt::Debug;
12use std::marker::PhantomData;
13use std::mem;
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17use anyhow::anyhow;
18use differential_dataflow::difference::Monoid;
19use differential_dataflow::lattice::Lattice;
20use differential_dataflow::trace::Description;
21use futures::{Stream, pin_mut};
22use futures_util::StreamExt;
23use itertools::Either;
24use mz_dyncfg::{Config, ParameterScope};
25use mz_ore::cast::CastFrom;
26use mz_ore::error::ErrorExt;
27use mz_ore::now::NowFn;
28use mz_ore::soft_assert_or_log;
29use mz_persist::location::Blob;
30use mz_persist_types::part::Part;
31use mz_persist_types::{Codec, Codec64};
32use timely::PartialOrder;
33use timely::progress::{Antichain, Timestamp};
34use tokio::sync::mpsc::Sender;
35use tokio::sync::{TryAcquireError, mpsc, oneshot};
36use tracing::{Instrument, Span, debug, debug_span, error, trace, warn};
37
38use crate::async_runtime::IsolatedRuntime;
39use crate::batch::{BatchBuilderConfig, BatchBuilderInternal, BatchParts, PartDeletes};
40use crate::cfg::{
41    COMPACTION_HEURISTIC_MIN_INPUTS, COMPACTION_HEURISTIC_MIN_PARTS,
42    COMPACTION_HEURISTIC_MIN_UPDATES, COMPACTION_MEMORY_BOUND_BYTES,
43    GC_BLOB_DELETE_CONCURRENCY_LIMIT, MiB,
44};
45use crate::fetch::{FetchBatchFilter, FetchConfig};
46use crate::internal::encoding::Schemas;
47use crate::internal::gc::GarbageCollector;
48use crate::internal::machine::Machine;
49use crate::internal::maintenance::RoutineMaintenance;
50use crate::internal::metrics::ShardMetrics;
51use crate::internal::state::{HollowBatch, RunMeta, RunOrder, RunPart};
52use crate::internal::trace::{
53    ActiveCompaction, ApplyMergeResult, CompactionInput, FueledMergeRes, IdHollowBatch, SpineId,
54    id_range,
55};
56use crate::iter::{Consolidator, StructuredSort};
57use crate::{Metrics, PersistConfig, ShardId};
58
59/// A request for compaction.
60///
61/// This is similar to FueledMergeReq, but intentionally a different type. If we
62/// move compaction to an rpc server, this one will become a protobuf; the type
63/// parameters will become names of codecs to look up in some registry.
64#[derive(Debug, Clone)]
65pub struct CompactReq<T> {
66    /// The shard the input and output batches belong to.
67    pub shard_id: ShardId,
68    /// A description for the output batch.
69    pub desc: Description<T>,
70    /// The updates to include in the output batch. Any data in these outside of
71    /// the output descriptions bounds should be ignored.
72    pub inputs: Vec<IdHollowBatch<T>>,
73}
74
75/// A response from compaction.
76#[derive(Debug)]
77pub struct CompactRes<T> {
78    /// The compacted batch.
79    pub output: HollowBatch<T>,
80    /// The runs that were compacted together to produce the output batch.
81    pub input: CompactionInput,
82}
83
84/// A snapshot of dynamic configs to make it easier to reason about an
85/// individual run of compaction.
86#[derive(Debug, Clone)]
87pub struct CompactConfig {
88    pub(crate) compaction_memory_bound_bytes: usize,
89    pub(crate) compaction_yield_after_n_updates: usize,
90    pub(crate) version: semver::Version,
91    pub(crate) batch: BatchBuilderConfig,
92    pub(crate) fetch_config: FetchConfig,
93    pub(crate) now: NowFn,
94}
95
96impl CompactConfig {
97    /// Initialize the compaction config from Persist configuration.
98    pub fn new(value: &PersistConfig, shard_id: ShardId) -> Self {
99        CompactConfig {
100            compaction_memory_bound_bytes: COMPACTION_MEMORY_BOUND_BYTES.get(value),
101            compaction_yield_after_n_updates: value.compaction_yield_after_n_updates,
102            version: value.build_version.clone(),
103            batch: BatchBuilderConfig::new(value, shard_id),
104            fetch_config: FetchConfig::from_persist_config(value),
105            now: value.now.clone(),
106        }
107    }
108}
109
110/// A service for performing physical and logical compaction.
111///
112/// This will possibly be called over RPC in the future. Physical compaction is
113/// merging adjacent batches. Logical compaction is advancing timestamps to a
114/// new since and consolidating the resulting updates.
115#[derive(Debug)]
116pub struct Compactor<K, V, T, D> {
117    cfg: PersistConfig,
118    metrics: Arc<Metrics>,
119    sender: Sender<(
120        Instant,
121        CompactReq<T>,
122        Machine<K, V, T, D>,
123        oneshot::Sender<Result<(), anyhow::Error>>,
124    )>,
125    _phantom: PhantomData<fn() -> D>,
126}
127
128impl<K, V, T, D> Clone for Compactor<K, V, T, D> {
129    fn clone(&self) -> Self {
130        Compactor {
131            cfg: self.cfg.clone(),
132            metrics: Arc::clone(&self.metrics),
133            sender: self.sender.clone(),
134            _phantom: Default::default(),
135        }
136    }
137}
138
139/// In Compactor::compact_and_apply_background, the minimum amount of time to
140/// allow a compaction request to run before timing it out. A request may be
141/// given a timeout greater than this value depending on the inputs' size
142pub(crate) const COMPACTION_MINIMUM_TIMEOUT: Config<Duration> = Config::new(
143    "persist_compaction_minimum_timeout",
144    Duration::from_secs(90),
145    "\
146    The minimum amount of time to allow a persist compaction request to run \
147    before timing it out (Materialize).",
148    ParameterScope::Environment,
149);
150
151pub(crate) const COMPACTION_CHECK_PROCESS_FLAG: Config<bool> = Config::new(
152    "persist_compaction_check_process_flag",
153    true,
154    "Whether Compactor will obey the process_requests flag in PersistConfig, \
155        which allows dynamically disabling compaction. If false, all compaction requests will be processed.",
156    ParameterScope::Environment,
157);
158
159/// Create a `[CompactionInput::IdRange]` from a set of `SpineId`s.
160fn input_id_range(ids: BTreeSet<SpineId>) -> CompactionInput {
161    let id = id_range(ids);
162
163    CompactionInput::IdRange(id)
164}
165
166impl<K, V, T, D> Compactor<K, V, T, D>
167where
168    K: Debug + Codec,
169    V: Debug + Codec,
170    T: Timestamp + Lattice + Codec64 + Sync,
171    D: Monoid + Ord + Codec64 + Send + Sync,
172{
173    pub fn new(
174        cfg: PersistConfig,
175        metrics: Arc<Metrics>,
176        gc: GarbageCollector<K, V, T, D>,
177    ) -> Self {
178        let (compact_req_sender, mut compact_req_receiver) = mpsc::channel::<(
179            Instant,
180            CompactReq<T>,
181            Machine<K, V, T, D>,
182            oneshot::Sender<Result<(), anyhow::Error>>,
183        )>(cfg.compaction_queue_size);
184        let concurrency_limit = Arc::new(tokio::sync::Semaphore::new(
185            cfg.compaction_concurrency_limit,
186        ));
187        let check_process_requests = COMPACTION_CHECK_PROCESS_FLAG.handle(&cfg.configs);
188        let process_requests = Arc::clone(&cfg.compaction_process_requests);
189
190        // spin off a single task responsible for executing compaction requests.
191        // work is enqueued into the task through a channel
192        let _worker_handle = mz_ore::task::spawn(|| "PersistCompactionScheduler", async move {
193            while let Some((enqueued, req, machine, completer)) = compact_req_receiver.recv().await
194            {
195                assert_eq!(req.shard_id, machine.shard_id());
196                let metrics = Arc::clone(&machine.applier.metrics);
197
198                // Only allow skipping compaction requests if the dyncfg is enabled.
199                if check_process_requests.get()
200                    && !process_requests.load(std::sync::atomic::Ordering::Relaxed)
201                {
202                    // Respond to the requester, track in our metrics, and log
203                    // that compaction is disabled.
204                    let _ = completer.send(Err(anyhow::anyhow!("compaction disabled")));
205                    metrics.compaction.disabled.inc();
206                    tracing::warn!(shard_id = ?req.shard_id, "Dropping compaction request on the floor.");
207
208                    continue;
209                }
210
211                let permit = {
212                    let inner = Arc::clone(&concurrency_limit);
213                    // perform a non-blocking attempt to acquire a permit so we can
214                    // record how often we're ever blocked on the concurrency limit
215                    match inner.try_acquire_owned() {
216                        Ok(permit) => permit,
217                        Err(TryAcquireError::NoPermits) => {
218                            metrics.compaction.concurrency_waits.inc();
219                            Arc::clone(&concurrency_limit)
220                                .acquire_owned()
221                                .await
222                                .expect("semaphore is never closed")
223                        }
224                        Err(TryAcquireError::Closed) => {
225                            // should never happen in practice. the semaphore is
226                            // never explicitly closed, nor will it close on Drop
227                            warn!("semaphore for shard {} is closed", machine.shard_id());
228                            continue;
229                        }
230                    }
231                };
232                metrics
233                    .compaction
234                    .queued_seconds
235                    .inc_by(enqueued.elapsed().as_secs_f64());
236
237                let compact_span =
238                    debug_span!(parent: None, "compact::apply", shard_id=%machine.shard_id());
239                compact_span.follows_from(&Span::current());
240                let gc = gc.clone();
241                mz_ore::task::spawn(|| "PersistCompactionWorker", async move {
242                    let res = Self::compact_and_apply(&machine, req)
243                        .instrument(compact_span)
244                        .await;
245
246                    match res {
247                        Ok(maintenance) => maintenance.start_performing(&machine, &gc),
248                        Err(err) => {
249                            debug!(shard_id =? machine.shard_id(), "compaction failed: {err:#}")
250                        }
251                    }
252
253                    // we can safely ignore errors here, it's possible the caller
254                    // wasn't interested in waiting and dropped their receiver
255                    let _ = completer.send(Ok(()));
256
257                    // moves `permit` into async scope so it can be dropped upon completion
258                    drop(permit);
259                });
260            }
261        });
262
263        Compactor {
264            cfg,
265            metrics,
266            sender: compact_req_sender,
267            _phantom: PhantomData,
268        }
269    }
270
271    /// Enqueues a [CompactReq] to be consumed by the compaction background task when available.
272    ///
273    /// Returns a receiver that indicates when compaction has completed. The receiver can be
274    /// safely dropped at any time if the caller does not wish to wait on completion.
275    pub fn compact_and_apply_background(
276        &self,
277        req: CompactReq<T>,
278        machine: &Machine<K, V, T, D>,
279    ) -> Option<oneshot::Receiver<Result<(), anyhow::Error>>> {
280        // Run some initial heuristics to ignore some requests for compaction.
281        // We don't gain much from e.g. compacting two very small batches that
282        // were just written, but it does result in non-trivial blob traffic
283        // (especially in aggregate). This heuristic is something we'll need to
284        // tune over time.
285        let should_compact = req.inputs.len() >= COMPACTION_HEURISTIC_MIN_INPUTS.get(&self.cfg)
286            || req
287                .inputs
288                .iter()
289                .map(|x| x.batch.part_count())
290                .sum::<usize>()
291                >= COMPACTION_HEURISTIC_MIN_PARTS.get(&self.cfg)
292            || req.inputs.iter().map(|x| x.batch.len).sum::<usize>()
293                >= COMPACTION_HEURISTIC_MIN_UPDATES.get(&self.cfg);
294        if !should_compact {
295            self.metrics.compaction.skipped.inc();
296            return None;
297        }
298
299        let (compaction_completed_sender, compaction_completed_receiver) = oneshot::channel();
300        let new_compaction_sender = self.sender.clone();
301
302        self.metrics.compaction.requested.inc();
303        // NB: we intentionally pass along the input machine, as it ought to come from the
304        // writer that generated the compaction request / maintenance. this machine has a
305        // spine structure that generated the request, so it has a much better chance of
306        // merging and committing the result than a machine kept up-to-date through state
307        // diffs, which may have a different spine structure less amenable to merging.
308        let send = new_compaction_sender.try_send((
309            Instant::now(),
310            req,
311            machine.clone(),
312            compaction_completed_sender,
313        ));
314        if let Err(_) = send {
315            self.metrics.compaction.dropped.inc();
316            return None;
317        }
318
319        Some(compaction_completed_receiver)
320    }
321
322    pub(crate) async fn compact_and_apply(
323        machine: &Machine<K, V, T, D>,
324        req: CompactReq<T>,
325    ) -> Result<RoutineMaintenance, anyhow::Error> {
326        let metrics = Arc::clone(&machine.applier.metrics);
327        metrics.compaction.started.inc();
328        let start = Instant::now();
329
330        // pick a timeout for our compaction request proportional to the amount
331        // of data that must be read (with a minimum set by PersistConfig)
332        let total_input_bytes = req
333            .inputs
334            .iter()
335            .map(|batch| batch.batch.encoded_size_bytes())
336            .sum::<usize>();
337        let timeout = Duration::max(
338            // either our minimum timeout
339            COMPACTION_MINIMUM_TIMEOUT.get(&machine.applier.cfg),
340            // or 1s per MB of input data
341            Duration::from_secs(u64::cast_from(total_input_bytes / MiB)),
342        );
343        // always use most recent schema from all the Runs we're compacting to prevent Compactors
344        // created before the schema was evolved, from trying to "de-evolve" a Part.
345        let Some(compaction_schema_id) = req
346            .inputs
347            .iter()
348            .flat_map(|batch| batch.batch.run_meta.iter())
349            .filter_map(|run_meta| run_meta.schema)
350            // It's an invariant that SchemaIds are ordered.
351            .max()
352        else {
353            metrics.compaction.schema_selection.no_schema.inc();
354            metrics.compaction.failed.inc();
355            return Err(anyhow!(
356                "compacting {shard_id} and spine ids {spine_ids}: could not determine schema id from inputs",
357                shard_id = req.shard_id,
358                spine_ids = mz_ore::str::separated(", ", req.inputs.iter().map(|i| i.id))
359            ));
360        };
361        let Some((key_schema, val_schema)) = machine.get_schema(compaction_schema_id) else {
362            metrics.compaction.schema_selection.no_schema.inc();
363            metrics.compaction.failed.inc();
364            return Err(anyhow!(
365                "compacting {shard_id} and spine ids {spine_ids}: schema id {compaction_schema_id} not present in machine state",
366                shard_id = req.shard_id,
367                spine_ids = mz_ore::str::separated(", ", req.inputs.iter().map(|i| i.id))
368            ));
369        };
370
371        metrics.compaction.schema_selection.recent_schema.inc();
372
373        let compaction_schema = Schemas {
374            id: Some(compaction_schema_id),
375            key: Arc::new(key_schema),
376            val: Arc::new(val_schema),
377        };
378
379        trace!(
380            "compaction request for {}MBs ({} bytes), with timeout of {}s, and schema {:?}.",
381            total_input_bytes / MiB,
382            total_input_bytes,
383            timeout.as_secs_f64(),
384            compaction_schema.id,
385        );
386
387        let isolated_runtime = Arc::clone(&machine.isolated_runtime);
388        let machine_clone = machine.clone();
389        let metrics_clone = Arc::clone(&machine.applier.metrics);
390        let compact_span = debug_span!("compact::consolidate");
391        let res = tokio::time::timeout(
392            timeout,
393            // Compaction is cpu intensive, so be polite and spawn it on the isolated runtime.
394            isolated_runtime.spawn_named(
395                || "persist::compact::consolidate",
396                async move {
397                    // If the batches we are compacting are written with old versions of persist,
398                    // we may not have run UUIDs for them, meaning we don't have enough info to
399                    // safely compact them incrementally.
400                    let all_runs_have_uuids = req
401                        .inputs
402                        .iter()
403                        .all(|x| x.batch.runs().all(|(meta, _)| meta.id.is_some()));
404                    let all_runs_have_len = req
405                        .inputs
406                        .iter()
407                        .all(|x| x.batch.runs().all(|(meta, _)| meta.len.is_some()));
408
409                    let compact_cfg =
410                        CompactConfig::new(&machine_clone.applier.cfg, machine_clone.shard_id());
411                    let incremental_enabled = compact_cfg.batch.enable_incremental_compaction
412                        && all_runs_have_uuids
413                        && all_runs_have_len;
414                    let stream = Self::compact_stream(
415                        compact_cfg,
416                        Arc::clone(&machine_clone.applier.state_versions.blob),
417                        Arc::clone(&metrics_clone),
418                        Arc::clone(&machine_clone.applier.shard_metrics),
419                        Arc::clone(&machine_clone.isolated_runtime),
420                        req.clone(),
421                        compaction_schema,
422                        incremental_enabled,
423                    );
424
425                    let maintenance = if incremental_enabled {
426                        let mut maintenance = RoutineMaintenance::default();
427                        pin_mut!(stream);
428                        while let Some(res) = stream.next().await {
429                            let res = res?;
430                            let new_maintenance =
431                                Self::apply(res, &metrics_clone, &machine_clone).await?;
432                            maintenance.merge(new_maintenance);
433                        }
434                        maintenance
435                    } else {
436                        let res = Self::compact_all(stream, req.clone()).await?;
437                        Self::apply(
438                            FueledMergeRes {
439                                output: res.output,
440                                input: CompactionInput::Legacy,
441                                new_active_compaction: None,
442                            },
443                            &metrics_clone,
444                            &machine_clone,
445                        )
446                        .await?
447                    };
448
449                    Ok::<_, anyhow::Error>(maintenance)
450                }
451                .instrument(compact_span),
452            ),
453        )
454        .await;
455
456        metrics
457            .compaction
458            .seconds
459            .inc_by(start.elapsed().as_secs_f64());
460        let res = res.map_err(|e| {
461            metrics.compaction.timed_out.inc();
462            anyhow!(
463                "compaction timed out after {}s: {}",
464                timeout.as_secs_f64(),
465                e
466            )
467        })?;
468
469        match res {
470            Ok(maintenance) => Ok(maintenance),
471            Err(err) => {
472                metrics.compaction.failed.inc();
473                debug!(
474                    "compaction for {} failed: {}",
475                    machine.shard_id(),
476                    err.display_with_causes()
477                );
478                Err(err)
479            }
480        }
481    }
482
483    pub async fn compact_all(
484        stream: impl Stream<Item = Result<FueledMergeRes<T>, anyhow::Error>>,
485        req: CompactReq<T>,
486    ) -> Result<CompactRes<T>, anyhow::Error> {
487        pin_mut!(stream);
488
489        let mut all_parts = vec![];
490        let mut all_run_splits = vec![];
491        let mut all_run_meta = vec![];
492        let mut len = 0;
493
494        while let Some(res) = stream.next().await {
495            let res = res?.output;
496            let (parts, updates, run_meta, run_splits) =
497                (res.parts, res.len, res.run_meta, res.run_splits);
498
499            if updates == 0 {
500                continue;
501            }
502
503            let run_offset = all_parts.len();
504            if !all_parts.is_empty() {
505                all_run_splits.push(run_offset);
506            }
507            all_run_splits.extend(run_splits.iter().map(|r| r + run_offset));
508            all_run_meta.extend(run_meta);
509            all_parts.extend(parts);
510            len += updates;
511        }
512
513        let batches = req.inputs.iter().map(|x| x.id).collect::<BTreeSet<_>>();
514        let input = input_id_range(batches);
515
516        Ok(CompactRes {
517            output: HollowBatch::new(
518                req.desc.clone(),
519                all_parts,
520                len,
521                all_run_meta,
522                all_run_splits,
523            ),
524            input,
525        })
526    }
527
528    pub async fn apply(
529        res: FueledMergeRes<T>,
530        metrics: &Metrics,
531        machine: &Machine<K, V, T, D>,
532    ) -> Result<RoutineMaintenance, anyhow::Error> {
533        let (apply_merge_result, maintenance) = machine.merge_res(&res).await;
534
535        match &apply_merge_result {
536            ApplyMergeResult::AppliedExact => {
537                metrics.compaction.applied.inc();
538                metrics.compaction.applied_exact_match.inc();
539                machine.applier.shard_metrics.compaction_applied.inc();
540            }
541            ApplyMergeResult::AppliedSubset => {
542                metrics.compaction.applied.inc();
543                metrics.compaction.applied_subset_match.inc();
544                machine.applier.shard_metrics.compaction_applied.inc();
545            }
546            ApplyMergeResult::NotAppliedNoMatch
547            | ApplyMergeResult::NotAppliedInvalidSince
548            | ApplyMergeResult::NotAppliedTooManyUpdates => {
549                if let ApplyMergeResult::NotAppliedTooManyUpdates = &apply_merge_result {
550                    metrics.compaction.not_applied_too_many_updates.inc();
551                }
552                metrics.compaction.noop.inc();
553                let mut part_deletes = PartDeletes::default();
554                for part in &res.output.parts {
555                    part_deletes.add(part);
556                }
557                part_deletes
558                    .delete(
559                        machine.applier.state_versions.blob.as_ref(),
560                        machine.shard_id(),
561                        GC_BLOB_DELETE_CONCURRENCY_LIMIT.get(&machine.applier.cfg),
562                        &*metrics,
563                        &metrics.retries.external.compaction_noop_delete,
564                    )
565                    .await;
566            }
567        };
568
569        Ok(maintenance)
570    }
571
572    /// Compacts input batches in bounded memory.
573    ///
574    /// The memory bound is broken into pieces:
575    ///     1. in-progress work
576    ///     2. fetching parts from runs
577    ///     3. additional in-flight requests to Blob
578    ///
579    /// 1. In-progress work is bounded by 2 * [BatchBuilderConfig::blob_target_size]. This
580    ///    usage is met at two mutually exclusive moments:
581    ///   * When reading in a part, we hold the columnar format in memory while writing its
582    ///     contents into a heap.
583    ///   * When writing a part, we hold a temporary updates buffer while encoding/writing
584    ///     it into a columnar format for Blob.
585    ///
586    /// 2. When compacting runs, only 1 part from each one is held in memory at a time.
587    ///    Compaction will determine an appropriate number of runs to compact together
588    ///    given the memory bound and accounting for the reservation in (1). A minimum
589    ///    of 2 * [BatchBuilderConfig::blob_target_size] of memory is expected, to be
590    ///    able to at least have the capacity to compact two runs together at a time,
591    ///    and more runs will be compacted together if more memory is available.
592    ///
593    /// 3. If there is excess memory after accounting for (1) and (2), we increase the
594    ///    number of outstanding parts we can keep in-flight to Blob.
595    pub fn compact_stream(
596        cfg: CompactConfig,
597        blob: Arc<dyn Blob>,
598        metrics: Arc<Metrics>,
599        shard_metrics: Arc<ShardMetrics>,
600        isolated_runtime: Arc<IsolatedRuntime>,
601        req: CompactReq<T>,
602        write_schemas: Schemas<K, V>,
603        incremental_enabled: bool,
604    ) -> impl Stream<Item = Result<FueledMergeRes<T>, anyhow::Error>> {
605        async_stream::stream! {
606            let () = Self::validate_req(&req)?;
607
608            // We introduced a fast-path optimization in https://github.com/MaterializeInc/materialize/pull/15363
609            // but had to revert it due to a very scary bug. Here we count how many of our compaction reqs
610            // could be eligible for the optimization to better understand whether it's worth trying to
611            // reintroduce it.
612            let mut single_nonempty_batch = None;
613            for batch in &req.inputs {
614                if batch.batch.len > 0 {
615                    match single_nonempty_batch {
616                        None => single_nonempty_batch = Some(batch),
617                        Some(_previous_nonempty_batch) => {
618                            single_nonempty_batch = None;
619                            break;
620                        }
621                    }
622                }
623            }
624            if let Some(single_nonempty_batch) = single_nonempty_batch {
625                if single_nonempty_batch.batch.run_splits.len() == 0
626                    && single_nonempty_batch.batch.desc.since()
627                        != &Antichain::from_elem(T::minimum())
628                {
629                    metrics.compaction.fast_path_eligible.inc();
630                }
631            }
632
633            // Reserve space for the in-progress part to be held in-mem representation and columnar -
634            let in_progress_part_reserved_memory_bytes = 2 * cfg.batch.blob_target_size;
635            // - then remaining memory will go towards pulling down as many runs as we can.
636            // We'll always do at least two runs per chunk, which means we may go over this limit
637            // if parts are large or the limit is low... though we do at least increment a metric
638            // when that happens.
639            let run_reserved_memory_bytes = cfg
640                .compaction_memory_bound_bytes
641                .saturating_sub(in_progress_part_reserved_memory_bytes);
642
643            let chunked_runs = Self::chunk_runs(
644                &req,
645                &cfg,
646                &*metrics,
647                run_reserved_memory_bytes,
648                req.desc.since()
649            );
650            let total_chunked_runs = chunked_runs.len();
651
652            let parts_before = req.inputs.iter()
653                .map(|x| x.batch.parts.len()).sum::<usize>();
654            let parts_after = chunked_runs.iter()
655                .flat_map(|(_, _, runs, _)| {
656                    runs.iter().map(|(_, _, parts)| parts.len())
657                })
658                .sum::<usize>();
659            assert_eq!(
660                parts_before, parts_after,
661                "chunking should not change the number of parts",
662            );
663
664            for (applied, (input, desc, runs, run_chunk_max_memory_usage)) in
665                chunked_runs.into_iter().enumerate()
666            {
667                metrics.compaction.chunks_compacted.inc();
668                metrics
669                    .compaction
670                    .runs_compacted
671                    .inc_by(u64::cast_from(runs.len()));
672
673                // given the runs we actually have in our batch, we might have extra memory
674                // available. we reserved enough space to always have 1 in-progress part in
675                // flight, but if we have excess, we can use it to increase our write parallelism
676                let extra_outstanding_parts = (run_reserved_memory_bytes
677                    .saturating_sub(run_chunk_max_memory_usage))
678                    / cfg.batch.blob_target_size;
679                let mut run_cfg = cfg.clone();
680                run_cfg.batch.batch_builder_max_outstanding_parts = 1 + extra_outstanding_parts;
681
682                let desc = if incremental_enabled {
683                    desc
684                } else {
685                    req.desc.clone()
686                };
687
688                let runs = runs.iter()
689                    .map(|(desc, meta, run)| (*desc, *meta, *run))
690                    .collect::<Vec<_>>();
691
692                let batch = Self::compact_runs(
693                    &run_cfg,
694                    &req.shard_id,
695                    &desc,
696                    runs,
697                    Arc::clone(&blob),
698                    Arc::clone(&metrics),
699                    Arc::clone(&shard_metrics),
700                    Arc::clone(&isolated_runtime),
701                    write_schemas.clone(),
702                )
703                .await?;
704
705                assert!(
706                    (batch.len == 0 && batch.parts.len() == 0)
707                        || (batch.len > 0 && batch.parts.len() > 0),
708                    "updates={}, parts={}",
709                    batch.len,
710                    batch.parts.len(),
711                );
712
713                // Set up active compaction metadata
714                let clock = cfg.now.clone();
715                let active_compaction = if applied < total_chunked_runs - 1 {
716                    Some(ActiveCompaction { start_ms: clock() })
717                } else {
718                    None
719                };
720
721                let res = CompactRes {
722                    output: batch,
723                    input,
724                };
725
726                let res = FueledMergeRes {
727                    output: res.output,
728                    new_active_compaction: active_compaction,
729                    input: res.input,
730                };
731
732                yield Ok(res);
733            }
734        }
735    }
736
737    /// Compacts the input batches together, returning a single compacted batch.
738    /// Under the hood this just calls [Self::compact_stream] and
739    /// [Self::compact_all], but it is a convenience method that allows
740    /// the caller to not have to deal with the streaming API.
741    pub async fn compact(
742        cfg: CompactConfig,
743        blob: Arc<dyn Blob>,
744        metrics: Arc<Metrics>,
745        shard_metrics: Arc<ShardMetrics>,
746        isolated_runtime: Arc<IsolatedRuntime>,
747        req: CompactReq<T>,
748        write_schemas: Schemas<K, V>,
749    ) -> Result<CompactRes<T>, anyhow::Error> {
750        let stream = Self::compact_stream(
751            cfg,
752            Arc::clone(&blob),
753            Arc::clone(&metrics),
754            Arc::clone(&shard_metrics),
755            Arc::clone(&isolated_runtime),
756            req.clone(),
757            write_schemas,
758            false,
759        );
760
761        Self::compact_all(stream, req).await
762    }
763
764    /// Chunks runs with the following rules:
765    /// 1. Runs from multiple batches are allowed to be mixed as long as _every_ run in the
766    ///    batch is present in the chunk.
767    /// 2. Otherwise, runs are split into chunks of runs from a single batch.
768    fn chunk_runs<'a>(
769        req: &'a CompactReq<T>,
770        cfg: &CompactConfig,
771        metrics: &Metrics,
772        run_reserved_memory_bytes: usize,
773        since: &Antichain<T>,
774    ) -> Vec<(
775        CompactionInput,
776        Description<T>,
777        Vec<(&'a Description<T>, &'a RunMeta, &'a [RunPart<T>])>,
778        usize,
779    )> {
780        // Assert that all of the inputs are contiguous / can be compacted together.
781        let _ = input_id_range(req.inputs.iter().map(|x| x.id).collect());
782
783        // Iterate through batches by spine id.
784        let mut batches: Vec<_> = req.inputs.iter().map(|x| (x.id, &*x.batch)).collect();
785        batches.sort_by_key(|(id, _)| *id);
786
787        let mut chunks = vec![];
788        let mut current_chunk_ids = BTreeSet::new();
789        let mut current_chunk_descs = Vec::new();
790        let mut current_chunk_runs = vec![];
791        let mut current_chunk_max_memory_usage = 0;
792
793        fn max_part_bytes<T>(parts: &[RunPart<T>], cfg: &CompactConfig) -> usize {
794            parts
795                .iter()
796                .map(|p| p.max_part_bytes())
797                .max()
798                .unwrap_or(cfg.batch.blob_target_size)
799        }
800
801        fn desc_range<T: Timestamp>(
802            descs: impl IntoIterator<Item = Description<T>>,
803            since: Antichain<T>,
804        ) -> Description<T> {
805            let mut descs = descs.into_iter();
806            let first = descs.next().expect("non-empty set of descriptions");
807            let lower = first.lower().clone();
808            let mut upper = first.upper().clone();
809            for desc in descs {
810                assert_eq!(&upper, desc.lower());
811                upper = desc.upper().clone();
812            }
813            let upper = upper.clone();
814            Description::new(lower, upper, since)
815        }
816
817        for (spine_id, batch) in batches {
818            let batch_size = batch
819                .runs()
820                .map(|(_, parts)| max_part_bytes(parts, cfg))
821                .sum::<usize>();
822
823            let num_runs = batch.run_meta.len();
824
825            let runs = batch.runs().flat_map(|(meta, parts)| {
826                if meta.order.unwrap_or(RunOrder::Codec) == cfg.batch.preferred_order {
827                    Either::Left(std::iter::once((&batch.desc, meta, parts)))
828                } else {
829                    // The downstream consolidation step will handle a long run that's not in
830                    // the desired order by splitting it up into many single-element runs. This preserves
831                    // correctness, but it means that we may end up needing to iterate through
832                    // many more parts concurrently than expected, increasing memory use. Instead,
833                    // we break up those runs into individual batch parts, fetching hollow runs as
834                    // necessary, before they're grouped together to be passed to consolidation.
835                    // The downside is that this breaks the usual property that compaction produces
836                    // fewer runs than it takes in. This should generally be resolved by future
837                    // runs of compaction.
838                    soft_assert_or_log!(
839                        !parts.iter().any(|r| matches!(r, RunPart::Many(_))),
840                        "unexpected out-of-order hollow run"
841                    );
842                    Either::Right(
843                        parts
844                            .iter()
845                            .map(move |p| (&batch.desc, meta, std::slice::from_ref(p))),
846                    )
847                }
848            });
849
850            // Combine the given batch into the current chunk
851            // - if they fit within the memory budget,
852            // - if both have only at most a single run, so otherwise compaction wouldn't make progress.
853            if current_chunk_max_memory_usage + batch_size <= run_reserved_memory_bytes
854                || current_chunk_runs.len() + num_runs <= 2
855            {
856                if current_chunk_max_memory_usage + batch_size > run_reserved_memory_bytes {
857                    // We've chosen to merge these batches together despite being over budget,
858                    // which should be rare.
859                    metrics.compaction.memory_violations.inc();
860                }
861                current_chunk_ids.insert(spine_id);
862                current_chunk_descs.push(batch.desc.clone());
863                current_chunk_runs.extend(runs);
864                current_chunk_max_memory_usage += batch_size;
865                continue;
866            }
867
868            // Otherwise, we cannot mix this batch partially. Flush any existing mixed chunk first.
869            if !current_chunk_ids.is_empty() {
870                chunks.push((
871                    input_id_range(std::mem::take(&mut current_chunk_ids)),
872                    desc_range(mem::take(&mut current_chunk_descs), since.clone()),
873                    std::mem::take(&mut current_chunk_runs),
874                    current_chunk_max_memory_usage,
875                ));
876                current_chunk_max_memory_usage = 0;
877            }
878
879            // If the batch fits within limits, try and accumulate future batches into it.
880            if batch_size <= run_reserved_memory_bytes {
881                current_chunk_ids.insert(spine_id);
882                current_chunk_descs.push(batch.desc.clone());
883                current_chunk_runs.extend(runs);
884                current_chunk_max_memory_usage += batch_size;
885                continue;
886            }
887
888            // This batch is too large to compact with others, or even in a single go.
889            // Process this batch alone, splitting into single-batch chunks as needed.
890            let mut run_iter = runs.into_iter().peekable();
891            mz_ore::soft_assert_no_log!(current_chunk_ids.is_empty());
892            mz_ore::soft_assert_no_log!(current_chunk_descs.is_empty());
893            mz_ore::soft_assert_no_log!(current_chunk_runs.is_empty());
894            mz_ore::soft_assert_eq_no_log!(current_chunk_max_memory_usage, 0);
895            let mut current_chunk_run_ids = BTreeSet::new();
896
897            while let Some((desc, meta, parts)) = run_iter.next() {
898                let run_size = max_part_bytes(parts, cfg);
899                current_chunk_runs.push((desc, meta, parts));
900                current_chunk_max_memory_usage += run_size;
901                current_chunk_run_ids.extend(meta.id);
902
903                if let Some((_, _meta, next_parts)) = run_iter.peek() {
904                    let next_size = max_part_bytes(next_parts, cfg);
905                    if current_chunk_max_memory_usage + next_size > run_reserved_memory_bytes {
906                        // If the current chunk only has one run, record a memory violation metric.
907                        if current_chunk_runs.len() == 1 {
908                            metrics.compaction.memory_violations.inc();
909                            continue;
910                        }
911                        // Flush the current chunk and start a new one.
912                        chunks.push((
913                            CompactionInput::PartialBatch(
914                                spine_id,
915                                mem::take(&mut current_chunk_run_ids),
916                            ),
917                            desc_range([batch.desc.clone()], since.clone()),
918                            std::mem::take(&mut current_chunk_runs),
919                            current_chunk_max_memory_usage,
920                        ));
921                        current_chunk_max_memory_usage = 0;
922                    }
923                }
924            }
925
926            if !current_chunk_runs.is_empty() {
927                chunks.push((
928                    CompactionInput::PartialBatch(spine_id, mem::take(&mut current_chunk_run_ids)),
929                    desc_range([batch.desc.clone()], since.clone()),
930                    std::mem::take(&mut current_chunk_runs),
931                    current_chunk_max_memory_usage,
932                ));
933                current_chunk_max_memory_usage = 0;
934            }
935        }
936
937        // If we ended with a mixed-batch chunk in progress, flush it.
938        if !current_chunk_ids.is_empty() {
939            chunks.push((
940                input_id_range(current_chunk_ids),
941                desc_range(current_chunk_descs, since.clone()),
942                current_chunk_runs,
943                current_chunk_max_memory_usage,
944            ));
945        }
946
947        chunks
948    }
949
950    /// Compacts runs together. If the input runs are sorted, a single run will be created as output.
951    ///
952    /// Maximum possible memory usage is `(# runs + 2) * [crate::PersistConfig::blob_target_size]`
953    pub(crate) async fn compact_runs(
954        cfg: &CompactConfig,
955        shard_id: &ShardId,
956        desc: &Description<T>,
957        runs: Vec<(&Description<T>, &RunMeta, &[RunPart<T>])>,
958        blob: Arc<dyn Blob>,
959        metrics: Arc<Metrics>,
960        shard_metrics: Arc<ShardMetrics>,
961        isolated_runtime: Arc<IsolatedRuntime>,
962        write_schemas: Schemas<K, V>,
963    ) -> Result<HollowBatch<T>, anyhow::Error> {
964        // TODO: Figure out a more principled way to allocate our memory budget.
965        // Currently, we give any excess budget to write parallelism. If we had
966        // to pick between 100% towards writes vs 100% towards reads, then reads
967        // is almost certainly better, but the ideal is probably somewhere in
968        // between the two.
969        //
970        // For now, invent some some extra budget out of thin air for prefetch.
971        let prefetch_budget_bytes = 2 * cfg.batch.blob_target_size;
972
973        let mut timings = Timings::default();
974
975        let mut batch_cfg = cfg.batch.clone();
976
977        // Use compaction as a method of getting inline writes out of state, to
978        // make room for more inline writes. We could instead do this at the end
979        // of compaction by flushing out the batch, but doing it here based on
980        // the config allows BatchBuilder to do its normal pipelining of writes.
981        batch_cfg.inline_writes_single_max_bytes = 0;
982
983        let parts = BatchParts::new_ordered::<D>(
984            batch_cfg,
985            cfg.batch.preferred_order,
986            Arc::clone(&metrics),
987            Arc::clone(&shard_metrics),
988            *shard_id,
989            Arc::clone(&blob),
990            Arc::clone(&isolated_runtime),
991            &metrics.compaction.batch,
992        );
993        let mut batch = BatchBuilderInternal::<K, V, T, D>::new(
994            cfg.batch.clone(),
995            parts,
996            Arc::clone(&metrics),
997            write_schemas.clone(),
998            Arc::clone(&blob),
999            shard_id.clone(),
1000            cfg.version.clone(),
1001        );
1002
1003        let mut consolidator = Consolidator::new(
1004            format!(
1005                "{}[lower={:?},upper={:?}]",
1006                shard_id,
1007                desc.lower().elements(),
1008                desc.upper().elements()
1009            ),
1010            cfg.fetch_config.clone(),
1011            *shard_id,
1012            StructuredSort::<K, V, T, D>::new(write_schemas.clone()),
1013            blob,
1014            Arc::clone(&metrics),
1015            shard_metrics,
1016            metrics.read.compaction.clone(),
1017            FetchBatchFilter::Compaction {
1018                since: desc.since().clone(),
1019            },
1020            None,
1021            prefetch_budget_bytes,
1022        );
1023
1024        for (desc, meta, parts) in runs {
1025            consolidator.enqueue_run(desc, meta, parts.iter().cloned());
1026        }
1027
1028        let remaining_budget = consolidator.start_prefetches();
1029        if remaining_budget.is_none() {
1030            metrics.compaction.not_all_prefetched.inc();
1031        }
1032
1033        loop {
1034            let mut chunks = vec![];
1035            let mut total_bytes = 0;
1036            // We attempt to pull chunks out of the consolidator that match our target size,
1037            // but it's possible that we may get smaller chunks... for example, if not all
1038            // parts have been fetched yet. Loop until we've got enough data to justify flushing
1039            // it out to blob (or we run out of data.)
1040            while total_bytes < cfg.batch.blob_target_size {
1041                let fetch_start = Instant::now();
1042                let Some(chunk) = consolidator
1043                    .next_chunk(
1044                        cfg.compaction_yield_after_n_updates,
1045                        cfg.batch.blob_target_size - total_bytes,
1046                    )
1047                    .await?
1048                else {
1049                    break;
1050                };
1051                timings.part_fetching += fetch_start.elapsed();
1052                total_bytes += chunk.goodbytes();
1053                chunks.push(chunk);
1054                tokio::task::yield_now().await;
1055            }
1056
1057            // In the hopefully-common case of a single chunk, this will not copy.
1058            let Some(updates) = Part::concat(&chunks).expect("compaction produces well-typed data")
1059            else {
1060                break;
1061            };
1062            batch.flush_part(desc.clone(), updates).await;
1063        }
1064        let mut batch = batch.finish(desc.clone()).await?;
1065
1066        // We use compaction as a method of getting inline writes out of state,
1067        // to make room for more inline writes. This happens in
1068        // `CompactConfig::new` by overriding the inline writes threshold
1069        // config. This is a bit action-at-a-distance, so defensively detect if
1070        // this breaks here and log and correct it if so.
1071        let has_inline_parts = batch.batch.parts.iter().any(|x| x.is_inline());
1072        if has_inline_parts {
1073            error!(%shard_id, ?cfg, "compaction result unexpectedly had inline writes");
1074            let () = batch
1075                .flush_to_blob(
1076                    &cfg.batch,
1077                    &metrics.compaction.batch,
1078                    &isolated_runtime,
1079                    &write_schemas,
1080                )
1081                .await;
1082        }
1083
1084        timings.record(&metrics);
1085        Ok(batch.into_hollow_batch())
1086    }
1087
1088    fn validate_req(req: &CompactReq<T>) -> Result<(), anyhow::Error> {
1089        let mut frontier = req.desc.lower();
1090        for input in req.inputs.iter() {
1091            if PartialOrder::less_than(req.desc.since(), input.batch.desc.since()) {
1092                return Err(anyhow!(
1093                    "output since {:?} must be at or in advance of input since {:?}",
1094                    req.desc.since(),
1095                    input.batch.desc.since()
1096                ));
1097            }
1098            if frontier != input.batch.desc.lower() {
1099                return Err(anyhow!(
1100                    "invalid merge of non-consecutive batches {:?} vs {:?}",
1101                    frontier,
1102                    input.batch.desc.lower()
1103                ));
1104            }
1105            frontier = input.batch.desc.upper();
1106        }
1107        if frontier != req.desc.upper() {
1108            return Err(anyhow!(
1109                "invalid merge of non-consecutive batches {:?} vs {:?}",
1110                frontier,
1111                req.desc.upper()
1112            ));
1113        }
1114        Ok(())
1115    }
1116}
1117
1118#[derive(Debug, Default)]
1119struct Timings {
1120    part_fetching: Duration,
1121    heap_population: Duration,
1122}
1123
1124impl Timings {
1125    fn record(self, metrics: &Metrics) {
1126        // intentionally deconstruct so we don't forget to consider each field
1127        let Timings {
1128            part_fetching,
1129            heap_population,
1130        } = self;
1131
1132        metrics
1133            .compaction
1134            .steps
1135            .part_fetch_seconds
1136            .inc_by(part_fetching.as_secs_f64());
1137        metrics
1138            .compaction
1139            .steps
1140            .heap_population_seconds
1141            .inc_by(heap_population.as_secs_f64());
1142    }
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147    use mz_dyncfg::ConfigUpdates;
1148    use mz_ore::{assert_contains, assert_err};
1149    use mz_persist_types::codec_impls::StringSchema;
1150    use timely::progress::Antichain;
1151
1152    use crate::PersistLocation;
1153    use crate::batch::BLOB_TARGET_SIZE;
1154    use crate::internal::trace::SpineId;
1155    use crate::tests::{all_ok, expect_fetch_part, new_test_client_cache};
1156
1157    use super::*;
1158
1159    // A regression test for a bug caught during development of materialize#13160 (never
1160    // made it to main) where batches written by compaction would always have a
1161    // since of the minimum timestamp.
1162    #[mz_persist_proc::test(tokio::test)]
1163    #[cfg_attr(miri, ignore)] // unsupported operation: returning ready events from epoll_wait is not yet implemented
1164    async fn regression_minimum_since(dyncfgs: ConfigUpdates) {
1165        let data = vec![
1166            (("0".to_owned(), "zero".to_owned()), 0, 1),
1167            (("0".to_owned(), "zero".to_owned()), 1, -1),
1168            (("1".to_owned(), "one".to_owned()), 1, 1),
1169        ];
1170
1171        let cache = new_test_client_cache(&dyncfgs);
1172        cache.cfg.set_config(&BLOB_TARGET_SIZE, 100);
1173        let (mut write, _) = cache
1174            .open(PersistLocation::new_in_mem())
1175            .await
1176            .expect("client construction failed")
1177            .expect_open::<String, String, u64, i64>(ShardId::new())
1178            .await;
1179        let b0 = write
1180            .expect_batch(&data[..1], 0, 1)
1181            .await
1182            .into_hollow_batch();
1183        let b1 = write
1184            .expect_batch(&data[1..], 1, 2)
1185            .await
1186            .into_hollow_batch();
1187
1188        let req = CompactReq {
1189            shard_id: write.machine.shard_id(),
1190            desc: Description::new(
1191                b0.desc.lower().clone(),
1192                b1.desc.upper().clone(),
1193                Antichain::from_elem(10u64),
1194            ),
1195            inputs: vec![
1196                IdHollowBatch {
1197                    batch: Arc::new(b0),
1198                    id: SpineId(0, 1),
1199                },
1200                IdHollowBatch {
1201                    batch: Arc::new(b1),
1202                    id: SpineId(1, 2),
1203                },
1204            ],
1205        };
1206        let schemas = Schemas {
1207            id: None,
1208            key: Arc::new(StringSchema),
1209            val: Arc::new(StringSchema),
1210        };
1211        let res = Compactor::<String, String, u64, i64>::compact(
1212            CompactConfig::new(&write.cfg, write.shard_id()),
1213            Arc::clone(&write.blob),
1214            Arc::clone(&write.metrics),
1215            write.metrics.shards.shard(&write.machine.shard_id(), ""),
1216            Arc::new(IsolatedRuntime::new_for_tests()),
1217            req.clone(),
1218            schemas.clone(),
1219        )
1220        .await
1221        .expect("compaction failed");
1222
1223        assert_eq!(res.output.desc, req.desc);
1224        assert_eq!(res.output.len, 1);
1225        assert_eq!(res.output.part_count(), 1);
1226        let part = res.output.parts[0].expect_hollow_part();
1227        let (part, updates) = expect_fetch_part(
1228            write.blob.as_ref(),
1229            &part.key.complete(&write.machine.shard_id()),
1230            &write.metrics,
1231            &schemas,
1232        )
1233        .await;
1234        assert_eq!(part.desc, res.output.desc);
1235        assert_eq!(updates, all_ok(&data, 10));
1236    }
1237
1238    #[mz_persist_proc::test(tokio::test)]
1239    #[cfg_attr(miri, ignore)] // unsupported operation: returning ready events from epoll_wait is not yet implemented
1240    async fn disable_compaction(dyncfgs: ConfigUpdates) {
1241        let data = [
1242            (("0".to_owned(), "zero".to_owned()), 0, 1),
1243            (("0".to_owned(), "zero".to_owned()), 1, -1),
1244            (("1".to_owned(), "one".to_owned()), 1, 1),
1245        ];
1246
1247        let cache = new_test_client_cache(&dyncfgs);
1248        cache.cfg.set_config(&BLOB_TARGET_SIZE, 100);
1249        let (mut write, _) = cache
1250            .open(PersistLocation::new_in_mem())
1251            .await
1252            .expect("client construction failed")
1253            .expect_open::<String, String, u64, i64>(ShardId::new())
1254            .await;
1255        let b0 = write
1256            .expect_batch(&data[..1], 0, 1)
1257            .await
1258            .into_hollow_batch();
1259        let b1 = write
1260            .expect_batch(&data[1..], 1, 2)
1261            .await
1262            .into_hollow_batch();
1263
1264        let req = CompactReq {
1265            shard_id: write.machine.shard_id(),
1266            desc: Description::new(
1267                b0.desc.lower().clone(),
1268                b1.desc.upper().clone(),
1269                Antichain::from_elem(10u64),
1270            ),
1271            inputs: vec![
1272                IdHollowBatch {
1273                    batch: Arc::new(b0),
1274                    id: SpineId(0, 1),
1275                },
1276                IdHollowBatch {
1277                    batch: Arc::new(b1),
1278                    id: SpineId(1, 2),
1279                },
1280            ],
1281        };
1282        write.cfg.set_config(&COMPACTION_HEURISTIC_MIN_INPUTS, 1);
1283        let compactor = write.compact.as_ref().expect("compaction hard disabled");
1284
1285        write.cfg.disable_compaction();
1286        let result = compactor
1287            .compact_and_apply_background(req.clone(), &write.machine)
1288            .expect("listener")
1289            .await
1290            .expect("channel closed");
1291        assert_err!(result);
1292        assert_contains!(result.unwrap_err().to_string(), "compaction disabled");
1293
1294        write.cfg.enable_compaction();
1295        compactor
1296            .compact_and_apply_background(req, &write.machine)
1297            .expect("listener")
1298            .await
1299            .expect("channel closed")
1300            .expect("compaction success");
1301
1302        // Make sure our CYA dyncfg works.
1303        let data2 = [
1304            (("2".to_owned(), "two".to_owned()), 2, 1),
1305            (("2".to_owned(), "two".to_owned()), 3, -1),
1306            (("3".to_owned(), "three".to_owned()), 3, 1),
1307        ];
1308
1309        let b2 = write
1310            .expect_batch(&data2[..1], 2, 3)
1311            .await
1312            .into_hollow_batch();
1313        let b3 = write
1314            .expect_batch(&data2[1..], 3, 4)
1315            .await
1316            .into_hollow_batch();
1317
1318        let req = CompactReq {
1319            shard_id: write.machine.shard_id(),
1320            desc: Description::new(
1321                b2.desc.lower().clone(),
1322                b3.desc.upper().clone(),
1323                Antichain::from_elem(20u64),
1324            ),
1325            inputs: vec![
1326                IdHollowBatch {
1327                    batch: Arc::new(b2),
1328                    id: SpineId(0, 1),
1329                },
1330                IdHollowBatch {
1331                    batch: Arc::new(b3),
1332                    id: SpineId(1, 2),
1333                },
1334            ],
1335        };
1336        let compactor = write.compact.as_ref().expect("compaction hard disabled");
1337
1338        // When the dyncfg is set to false we should ignore the process flag.
1339        write.cfg.set_config(&COMPACTION_CHECK_PROCESS_FLAG, false);
1340        write.cfg.disable_compaction();
1341        // Compaction still succeeded!
1342        compactor
1343            .compact_and_apply_background(req, &write.machine)
1344            .expect("listener")
1345            .await
1346            .expect("channel closed")
1347            .expect("compaction success");
1348    }
1349}