Repository: cockroachdb/cockroach
Pull request #143769: backup: switch compactions to distributed SQL processors
Author: login: kev-cao
Labels: name: v25.2.0-prerelease
Created: 2025-03-31T21:24:20Z | Merged: 2025-04-04T03:31:12Z

Currently, compactions run locally on the coordinator node. The compaction work should be distributed across the cluster instead. This PR adds distSQL processors and planning logic to compactions. The spans generated by `generateAndSendImportSpans` are divvied up equally across the nodes in the cluster so that each node receives roughly the same number of spans. Progress updates are then sent back to the coordinator node and handled in the same way as the existing backup logic.

This does not include logic for tracing aggregation or checkpointing, which will be done in follow up PRs.

Epic: None

Release note: None


Diff:
diff --git a/pkg/backup/BUILD.bazel b/pkg/backup/BUILD.bazel
index f711bedefc82..b67325fcfd05 100644
--- a/pkg/backup/BUILD.bazel
+++ b/pkg/backup/BUILD.bazel
@@ -6,8 +6,6 @@ go_library(
     srcs = [
         "alter_backup_planning.go",
         "alter_backup_schedule.go",
-        "backup_compaction.go",
-        "backup_compaction_policy.go",
         "backup_job.go",
         "backup_metrics.go",
         "backup_planning.go",
@@ -16,6 +14,10 @@ go_library(
         "backup_processor_planning.go",
         "backup_span_coverage.go",
         "backup_telemetry.go",
+        "compaction_dist.go",
+        "compaction_job.go",
+        "compaction_policy.go",
+        "compaction_processor.go",
         "create_scheduled_backup.go",
         "generative_split_and_scatter_processor.go",
         "key_rewriter.go",
@@ -171,14 +173,15 @@ go_test(
         "alter_backup_schedule_test.go",
         "alter_backup_test.go",
         "backup_cloud_test.go",
-        "backup_compaction_policy_test.go",
-        "backup_compaction_test.go",
         "backup_intents_test.go",
         "backup_planning_test.go",
         "backup_tenant_test.go",
         "backup_test.go",
         "bench_covering_test.go",
         "bench_test.go",
+        "compaction_dist_test.go",
+        "compaction_policy_test.go",
+        "compaction_test.go",
         "create_scheduled_backup_test.go",
         "data_driven_generated_test.go",  # keep
         "datadriven_test.go",
diff --git a/pkg/backup/compaction_dist.go b/pkg/backup/compaction_dist.go
new file mode 100644
index 000000000000..8c0196c7c005
--- /dev/null
+++ b/pkg/backup/compaction_dist.go
@@ -0,0 +1,330 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupinfo"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobsprofiler"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/physicalplan"
+	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/util"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/errors"
+)
+
+// runCompactionPlan creates and runs a distsql plan to compact the spans
+// in the backup chain that need to be compacted. It sends updates from the
+// BulkProcessor to the provided progress channel. It is the caller's
+// responsibility to close the progress channel.
+func runCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	compactChain compactionChain,
+	manifest *backuppb.BackupManifest,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+	progCh chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	log.Infof(
+		ctx, "planning compaction of %d backups: %s",
+		len(compactChain.chainToCompact),
+		util.Map(compactChain.chainToCompact, func(m backuppb.BackupManifest) string {
+			return m.ID.String()
+		}),
+	)
+	backupLocalityMap, err := makeBackupLocalityMap(
+		compactChain.compactedLocalityInfo, execCtx.User(),
+	)
+	if err != nil {
+		return err
+	}
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, manifest.EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+	targetSize := targetRestoreSpanSize.Get(&execCtx.ExecCfg().Settings.SV)
+	maxFiles := maxFileCount.Get(&execCtx.ExecCfg().Settings.SV)
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		manifest.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	spansToCompact, err := getSpansToCompact(
+		ctx, execCtx, manifest, compactChain.chainToCompact, details, defaultStore, kmsEnv,
+	)
+	if err != nil {
+		return err
+	}
+	genSpan := func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error {
+		return errors.Wrap(generateAndSendImportSpans(
+			ctx,
+			spansToCompact,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			spanCh,
+		), "generateAndSendImportSpans")
+	}
+	dsp := execCtx.DistSQLPlanner()
+	plan, planCtx, err := createCompactionPlan(
+		ctx, execCtx, jobID, details, manifest, dsp, genSpan, spansToCompact, targetSize, maxFiles,
+	)
+	if err != nil {
+		return errors.Wrap(err, "creating compaction plan")
+	}
+	sql.FinalizePlan(ctx, planCtx, plan)
+
+	metaFn := func(_ context.Context, meta *execinfrapb.ProducerMetadata) error {
+		if meta.BulkProcessorProgress != nil {
+			progCh <- meta.BulkProcessorProgress
+		}
+
+		// TODO (kev-cao): Add channel for tracing aggregator events.
+		return nil
+	}
+	rowResultWriter := sql.NewRowResultWriter(nil)
+	recv := sql.MakeDistSQLReceiver(
+		ctx,
+		sql.NewMetadataCallbackWriter(rowResultWriter, metaFn),
+		tree.Rows,
+		nil, /* rangeCache */
+		nil, /* txn */
+		nil, /* clockUpdater */
+		execCtx.ExtendedEvalContext().Tracing,
+	)
+	defer recv.Release()
+
+	jobsprofiler.StorePlanDiagram(
+		ctx, execCtx.ExecCfg().DistSQLSrv.Stopper, plan, execCtx.ExecCfg().InternalDB, jobID,
+	)
+
+	evalCtxCopy := execCtx.ExtendedEvalContext().Copy()
+	dsp.Run(ctx, planCtx, nil /* txn */, plan, recv, evalCtxCopy, nil /* finishedSetupFn */)
+	return nil
+}
+
+// createCompactionPlan creates an un-finalized physical plan that will
+// distribute spans from a generator across the cluster for compaction.
+func createCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	manifest *backuppb.BackupManifest,
+	dsp *sql.DistSQLPlanner,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+	targetSize int64,
+	maxFiles int64,
+) (*sql.PhysicalPlan, *sql.PlanningCtx, error) {
+	numEntries, err := countRestoreSpanEntries(ctx, genSpan)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "counting number of restore span entries")
+	}
+
+	// TODO (kev-cao): Add support for execution locality.
+	planCtx, sqlInstanceIDs, err := dsp.SetupAllNodesPlanningWithOracle(
+		ctx, execCtx.ExtendedEvalContext(), execCtx.ExecCfg(),
+		physicalplan.DefaultReplicaChooser, roachpb.Locality{},
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	plan := planCtx.NewPhysicalPlan()
+	corePlacements, err := createCompactionCorePlacements(
+		ctx, jobID, execCtx.User(), details, manifest.ElidedPrefix, genSpan,
+		spansToCompact, sqlInstanceIDs, targetSize, maxFiles, numEntries,
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+	plan.AddNoInputStage(
+		corePlacements,
+		execinfrapb.PostProcessSpec{},
+		[]*types.T{},
+		execinfrapb.Ordering{},
+		nil, /* finalizeLastStageCb */
+	)
+	return plan, planCtx, nil
+}
+
+// countRestoreSpanEntries counts the number of restore span entries that will be
+// be delivered by the given generator.
+func countRestoreSpanEntries(
+	ctx context.Context,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+) (int, error) {
+	countSpansCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var numImportSpans int
+	countTasks := []func(ctx context.Context) error{
+		func(ctx context.Context) error {
+			for range countSpansCh {
+				numImportSpans++
+			}
+			return nil
+		},
+		func(ctx context.Context) error {
+			defer close(countSpansCh)
+			return genSpan(ctx, countSpansCh)
+		},
+	}
+	if err := ctxgroup.GoAndWait(ctx, countTasks...); err != nil {
+		return 0, errors.Wrapf(err, "counting number of spans to compact")
+	}
+	return numImportSpans, nil
+}
+
+// createCompactionCorePlacements takes spans from a generator and evenly
+// distributes them across nodes in the cluster, returning the core placements
+// reflecting that distribution.
+func createCompactionCorePlacements(
+	ctx context.Context,
+	jobID jobspb.JobID,
+	user username.SQLUsername,
+	details jobspb.BackupDetails,
+	elideMode execinfrapb.ElidePrefix,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+	sqlInstanceIDs []base.SQLInstanceID,
+	targetSize int64,
+	maxFiles int64,
+	numEntries int,
+) ([]physicalplan.ProcessorCorePlacement, error) {
+	numNodes := len(sqlInstanceIDs)
+	corePlacements := make([]physicalplan.ProcessorCorePlacement, numNodes)
+	for i := range corePlacements {
+		corePlacements[i].SQLInstanceID = sqlInstanceIDs[i]
+		corePlacements[i].Core.CompactBackups = &execinfrapb.CompactBackupsSpec{
+			JobID:       int64(jobID),
+			DefaultURI:  details.URI,
+			Destination: details.Destination,
+			Encryption:  details.EncryptionOptions,
+			StartTime:   details.StartTime,
+			EndTime:     details.EndTime,
+			ElideMode:   elideMode,
+			UserProto:   user.EncodeProto(),
+			Spans:       spansToCompact,
+			TargetSize:  targetSize,
+			MaxFiles:    maxFiles,
+		}
+	}
+
+	spanEntryCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var tasks []func(ctx context.Context) error
+	tasks = append(tasks, func(ctx context.Context) error {
+		defer close(spanEntryCh)
+		return genSpan(ctx, spanEntryCh)
+	})
+	tasks = append(tasks, func(ctx context.Context) error {
+		numEntriesPerNode := numEntries / numNodes
+		leftoverEntries := numEntries % numNodes
+		getTargetNumEntries := func(nodeIdx int) int {
+			if nodeIdx < leftoverEntries {
+				// This more evenly distributes the leftover entries across the nodes
+				// after doing integer division to assign the entries to the nodes.
+				return numEntriesPerNode + 1
+			}
+			return numEntriesPerNode
+		}
+		currNode := 0
+		entriesAdded := 0
+		currEntries := roachpb.SpanGroup{}
+		targetNumEntries := getTargetNumEntries(currNode)
+
+		for entry := range spanEntryCh {
+			currEntries.Add(entry.Span)
+			entriesAdded++
+			if entriesAdded == targetNumEntries {
+				corePlacements[currNode].SQLInstanceID = sqlInstanceIDs[currNode]
+				corePlacements[currNode].Core.CompactBackups.AssignedSpans = currEntries.Slice()
+
+				currNode++
+				targetNumEntries = getTargetNumEntries(currNode)
+				currEntries.Clear()
+				entriesAdded = 0
+			}
+			if currNode == numNodes {
+				return nil
+			}
+		}
+		return nil
+	})
+	if err := ctxgroup.GoAndWait(ctx, tasks...); err != nil {
+		return nil, errors.Wrapf(err, "distributing span entries to processors")
+	}
+	return corePlacements, nil
+}
+
+// getSpansToCompact returns all remaining spans the backup manifest that
+// need to be compacted.
+func getSpansToCompact(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	manifest *backuppb.BackupManifest,
+	backupChain []backuppb.BackupManifest,
+	details jobspb.BackupDetails,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+) (roachpb.Spans, error) {
+	var tables []catalog.TableDescriptor
+	for _, desc := range manifest.Descriptors {
+		catDesc := backupinfo.NewDescriptorForManifest(&desc)
+		if table, ok := catDesc.(catalog.TableDescriptor); ok {
+			tables = append(tables, table)
+		}
+	}
+	backupCodec, err := backupinfo.MakeBackupCodec(backupChain)
+	if err != nil {
+		return nil, err
+	}
+	spans, err := spansForAllRestoreTableIndexes(
+		backupCodec,
+		tables,
+		nil,   /* revs */
+		false, /* schemaOnly */
+		false, /* forOnlineRestore */
+	)
+	if err != nil {
+		return nil, err
+	}
+	completedSpans, completedIntroducedSpans, err := getCompletedSpans(
+		ctx, execCtx, manifest, defaultStore, details.EncryptionOptions, kmsEnv,
+	)
+	if err != nil {
+		return nil, err
+	}
+	spans = filterSpans(spans, completedSpans)
+	spans = filterSpans(spans, completedIntroducedSpans)
+	return spans, nil
+}
diff --git a/pkg/backup/compaction_dist_test.go b/pkg/backup/compaction_dist_test.go
new file mode 100644
index 000000000000..01617875a3f4
--- /dev/null
+++ b/pkg/backup/compaction_dist_test.go
@@ -0,0 +1,154 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+	"testing"
+
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/util/leaktest"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCreateCompactionCorePlacements(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	ctx := context.Background()
+	sp := func(start, end string) roachpb.Span {
+		return roachpb.Span{Key: roachpb.Key(start), EndKey: roachpb.Key(end)}
+	}
+	entries := func(spans ...roachpb.Span) []execinfrapb.RestoreSpanEntry {
+		var entries []execinfrapb.RestoreSpanEntry
+		for _, s := range spans {
+			entries = append(entries, execinfrapb.RestoreSpanEntry{Span: s})
+		}
+		return entries
+	}
+
+	testcases := []struct {
+		name                 string
+		entries              []execinfrapb.RestoreSpanEntry
+		numNodes             int
+		expectedDistribution [][]roachpb.Span
+	}{
+		{
+			name:     "single node, single span entry",
+			entries:  entries(sp("a", "b")),
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{sp("a", "b")},
+			},
+		},
+		{
+			name:     "single node, multiple span entries",
+			entries:  entries(sp("a", "b"), sp("c", "d"), sp("e", "f")),
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{sp("a", "b"), sp("c", "d"), sp("e", "f")},
+			},
+		},
+		{
+			name:     "single node, multiple overlapping span entries",
+			entries:  entries(sp("a", "b"), sp("b", "d"), sp("e", "f")),
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{sp("a", "d"), sp("e", "f")},
+			},
+		},
+		{
+			name:     "multiple nodes, single span entry",
+			entries:  entries(sp("a", "b")),
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{sp("a", "b")},
+				nil,
+				nil,
+			},
+		},
+		{
+			name: "multiple nodes, exact multiple of span entries",
+			entries: entries(
+				sp("a", "b"), sp("c", "d"),
+				sp("e", "f"), sp("g", "h"),
+				sp("i", "j"), sp("k", "l"),
+			),
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{sp("a", "b"), sp("c", "d")},
+				{sp("e", "f"), sp("g", "h")},
+				{sp("i", "j"), sp("k", "l")},
+			},
+		},
+		{
+			name: "multiple nodes, exact multiple of overlapping span entries",
+			entries: entries(
+				sp("a", "b"), sp("b", "d"), sp("d", "f"),
+				sp("f", "h"), sp("i", "j"), sp("j", "l"),
+			),
+			numNodes: 2,
+			expectedDistribution: [][]roachpb.Span{
+				{sp("a", "f")},
+				{sp("f", "h"), sp("i", "l")},
+			},
+		},
+		{
+			name: "multiple nodes, not exact multiple of span entries",
+			entries: entries(
+				sp("a", "b"), sp("c", "d"),
+				sp("e", "f"), sp("f", "h"),
+				sp("i", "j"),
+			),
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{sp("a", "b"), sp("c", "d")},
+				{sp("e", "h")},
+				{sp("i", "j")},
+			},
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			genSpan := func(_ context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error {
+				for _, entry := range tc.entries {
+					spanCh <- entry
+				}
+				return nil
+			}
+			sqlInstanceIDs := make([]base.SQLInstanceID, tc.numNodes)
+			for i := range tc.numNodes {
+				sqlInstanceIDs[i] = base.SQLInstanceID(i)
+			}
+			placements, err := createCompactionCorePlacements(
+				ctx,
+				0, /* jobID */
+				username.RootUserName(),
+				jobspb.BackupDetails{},
+				execinfrapb.ElidePrefix_None,
+				genSpan,
+				nil, /* spansToCompact */
+				sqlInstanceIDs,
+				0, /* targetSize */
+				0, /* maxFiles */
+				len(tc.entries),
+			)
+			require.NoError(t, err)
+			require.Equal(t, tc.numNodes, len(placements))
+			for i, expected := range tc.expectedDistribution {
+				core := placements[i].Core.CompactBackups
+				require.NotNil(t, core)
+				require.Equal(t, expected, core.AssignedSpans)
+			}
+		})
+	}
+}
diff --git a/pkg/backup/backup_compaction.go b/pkg/backup/compaction_job.go
similarity index 75%
rename from pkg/backup/backup_compaction.go
rename to pkg/backup/compaction_job.go
index ff8124b41462..7fb76abe615d 100644
--- a/pkg/backup/backup_compaction.go
+++ b/pkg/backup/compaction_job.go
@@ -6,7 +6,6 @@
 package backup
 
 import (
-	"bytes"
 	"context"
 	"math"
 	"strings"
@@ -17,16 +16,12 @@ import (
 	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
 	"github.com/cockroachdb/cockroach/pkg/backup/backupinfo"
 	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
-	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
 	"github.com/cockroachdb/cockroach/pkg/backup/backuputils"
-	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
 	"github.com/cockroachdb/cockroach/pkg/cloud"
 	"github.com/cockroachdb/cockroach/pkg/cloud/cloudpb"
 	"github.com/cockroachdb/cockroach/pkg/jobs"
 	"github.com/cockroachdb/cockroach/pkg/jobs/joberror"
 	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
-	"github.com/cockroachdb/cockroach/pkg/keys"
-	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
 	"github.com/cockroachdb/cockroach/pkg/multitenant/mtinfopb"
 	"github.com/cockroachdb/cockroach/pkg/roachpb"
 	"github.com/cockroachdb/cockroach/pkg/scheduledjobs"
@@ -40,13 +35,11 @@ import (
 	"github.com/cockroachdb/cockroach/pkg/sql/sem/builtins"
 	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
 	"github.com/cockroachdb/cockroach/pkg/sql/sessiondata"
-	"github.com/cockroachdb/cockroach/pkg/storage"
 	"github.com/cockroachdb/cockroach/pkg/util"
 	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
 	"github.com/cockroachdb/cockroach/pkg/util/hlc"
 	"github.com/cockroachdb/cockroach/pkg/util/log"
 	"github.com/cockroachdb/cockroach/pkg/util/retry"
-	"github.com/cockroachdb/cockroach/pkg/util/tracing"
 	"github.com/cockroachdb/cockroach/pkg/util/uuid"
 	"github.com/cockroachdb/errors"
 	"github.com/gogo/protobuf/types"
@@ -90,7 +83,10 @@ func maybeStartCompactionJob(
 		execCfg.InternalDB,
 		user,
 	)
-	chain, _, _, _, err := getBackupChain(ctx, execCfg, user, triggerJob, &kmsEnv)
+	chain, _, _, _, err := getBackupChain(
+		ctx, execCfg, user, triggerJob.Destination, triggerJob.EncryptionOptions,
+		triggerJob.EndTime, &kmsEnv,
+	)
 	if err != nil {
 		return 0, errors.Wrap(err, "failed to get backup chain")
 	}
@@ -203,7 +199,8 @@ func (b *backupResumer) ResumeCompaction(
 	// resolution and writing of backup lock due to the need to verify that the
 	// compaction chain is a valid chain.
 	prevManifests, localityInfo, encryption, allIters, err := getBackupChain(
-		ctx, execCtx.ExecCfg(), execCtx.User(), initialDetails, kmsEnv,
+		ctx, execCtx.ExecCfg(), execCtx.User(), initialDetails.Destination,
+		initialDetails.EncryptionOptions, initialDetails.EndTime, kmsEnv,
 	)
 	if err != nil {
 		return err
@@ -358,7 +355,9 @@ func (b *backupResumer) ResumeCompaction(
 	// dying), so if we receive a retryable error, re-plan and retry the backup.
 	// TODO (kev-cao): Add progress tracking to compactions.
 	for r := retry.StartWithCtx(ctx, retryOpts); r.Next(); {
-		if err = compactChain.Compact(ctx, execCtx, updatedDetails, backupManifest, defaultStore, kmsEnv); err == nil {
+		if err = doCompaction(
+			ctx, execCtx, b.job.ID(), updatedDetails, compactChain, backupManifest, defaultStore, kmsEnv,
+		); err == nil {
 			break
 		}
 
@@ -416,116 +415,6 @@ type compactionChain struct {
 	compactedIterFactory backupinfo.LayerToBackupManifestFileIterFactory
 }
 
-// Compact runs compaction on the chain according to the job details and
-// associated backup manifest.
-func (c *compactionChain) Compact(
-	ctx context.Context,
-	execCtx sql.JobExecContext,
-	details jobspb.BackupDetails,
-	backupManifest *backuppb.BackupManifest,
-	defaultStore cloud.ExternalStorage,
-	kmsEnv cloud.KMSEnv,
-) error {
-	ctx, span := tracing.ChildSpan(ctx, "backup.compaction")
-	defer span.Finish()
-	log.Infof(
-		ctx, "beginning compaction of %d backups: %s",
-		len(c.chainToCompact), util.Map(c.chainToCompact, func(m backuppb.BackupManifest) string {
-			return m.ID.String()
-		}),
-	)
-	backupLocalityMap, err := makeBackupLocalityMap(c.compactedLocalityInfo, execCtx.User())
-	if err != nil {
-		return err
-	}
-
-	introducedSpanFrontier, err := createIntroducedSpanFrontier(c.backupChain, backupManifest.EndTime)
-	if err != nil {
-		return err
-	}
-	defer introducedSpanFrontier.Release()
-
-	spanCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
-	backupCodec, err := backupinfo.MakeBackupCodec(c.chainToCompact)
-	if err != nil {
-		return err
-	}
-	var tables []catalog.TableDescriptor
-	for _, desc := range backupManifest.Descriptors {
-		catDesc := backupinfo.NewDescriptorForManifest(&desc)
-		if table, ok := catDesc.(catalog.TableDescriptor); ok {
-			tables = append(tables, table)
-		}
-	}
-	targetSize := targetRestoreSpanSize.Get(&execCtx.ExecCfg().Settings.SV)
-	maxFiles := maxFileCount.Get(&execCtx.ExecCfg().Settings.SV)
-
-	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
-	filter, err := makeSpanCoveringFilter(
-		backupManifest.Spans,
-		[]jobspb.RestoreProgress_FrontierEntry{},
-		introducedSpanFrontier,
-		targetSize,
-		maxFiles,
-	)
-	if err != nil {
-		return err
-	}
-	spans, err := spansForAllRestoreTableIndexes(
-		backupCodec,
-		tables,
-		nil,   /* revs */
-		false, /* schemaOnly */
-		false, /* forOnlineRestore */
-	)
-	if err != nil {
-		return err
-	}
-	completedSpans, completedIntroducedSpans, err := getCompletedSpans(
-		ctx, execCtx, backupManifest, defaultStore, details.EncryptionOptions, kmsEnv,
-	)
-	if err != nil {
-		return err
-	}
-	spans = filterSpans(spans, completedSpans)
-	spans = filterSpans(spans, completedIntroducedSpans)
-
-	genSpan := func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error {
-		defer close(spanCh)
-		if err != nil {
-			return err
-		}
-		return errors.Wrap(generateAndSendImportSpans(
-			ctx,
-			spans,
-			c.chainToCompact,
-			c.compactedIterFactory,
-			backupLocalityMap,
-			filter,
-			fsc,
-			spanCh,
-		), "generate and send import spans")
-	}
-
-	progCh := make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress)
-	var tasks []func(context.Context) error
-	encryption := details.EncryptionOptions
-	tasks = append(tasks, func(ctx context.Context) error {
-		return genSpan(ctx, spanCh)
-	})
-	tasks = append(tasks, func(ctx context.Context) error {
-		return runCompaction(ctx, execCtx, encryption, spanCh, details, backupManifest, progCh, defaultStore)
-	})
-	tasks = append(tasks, func(ctx context.Context) error {
-		return processProgress(ctx, backupManifest, progCh)
-	})
-
-	if err := ctxgroup.GoAndWait(ctx, tasks...); err != nil {
-		return err
-	}
-	return concludeBackupCompaction(ctx, execCtx, defaultStore, encryption, kmsEnv, backupManifest)
-}
-
 // lastBackup returns the last backup of the chain to compact.
 func (c *compactionChain) lastBackup() backuppb.BackupManifest {
 	return c.backupChain[c.endIdx-1]
@@ -533,6 +422,7 @@ func (c *compactionChain) lastBackup() backuppb.BackupManifest {
 
 // newCompactionChain returns a new compacted backup chain based on the specified start and end
 // timestamps from a chain of backups. The start and end times must specify specific backups.
+// layerToIterFactory must map accordingly to the passed in manifests.
 func newCompactionChain(
 	manifests []backuppb.BackupManifest,
 	start, end hlc.Timestamp,
@@ -581,156 +471,6 @@ func newCompactionChain(
 	}, nil
 }
 
-func runCompaction(
-	ctx context.Context,
-	execCtx sql.JobExecContext,
-	encryption *jobspb.BackupEncryptionOptions,
-	entries chan execinfrapb.RestoreSpanEntry,
-	details jobspb.BackupDetails,
-	manifest *backuppb.BackupManifest,
-	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
-	store cloud.ExternalStorage,
-) error {
-	defer close(progCh)
-	var encryptionOptions *kvpb.FileEncryptionOptions
-	if encryption != nil {
-		encryptionOptions = &kvpb.FileEncryptionOptions{Key: encryption.Key}
-	}
-	sinkConf := backupsink.SSTSinkConf{
-		ID:        execCtx.ExecCfg().DistSQLSrv.NodeID.SQLInstanceID(),
-		Enc:       encryptionOptions,
-		ProgCh:    progCh,
-		Settings:  &execCtx.ExecCfg().Settings.SV,
-		ElideMode: manifest.ElidedPrefix,
-	}
-	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
-	if err != nil {
-		return err
-	}
-	defer func() {
-		if err := sink.Flush(ctx); err != nil {
-			log.Warningf(ctx, "failed to flush sink: %v", err)
-			logClose(ctx, sink, "SST sink")
-		}
-	}()
-
-	for {
-		select {
-		case <-ctx.Done():
-			return ctx.Err()
-		case entry, ok := <-entries:
-			if !ok {
-				return nil
-			}
-
-			sstIter, err := openSSTs(ctx, execCtx, entry, encryptionOptions, details)
-			if err != nil {
-				return errors.Wrap(err, "opening SSTs")
-			}
-
-			if err := processSpanEntry(ctx, sstIter, sink); err != nil {
-				return errors.Wrap(err, "processing span entry")
-			}
-		}
-	}
-}
-
-func processSpanEntry(
-	ctx context.Context, sstIter mergedSST, sink *backupsink.SSTSinkKeyWriter,
-) error {
-	defer sstIter.cleanup()
-	entry := sstIter.entry
-	prefix, err := backupsink.ElidedPrefix(entry.Span.Key, entry.ElidedPrefix)
-	if err != nil {
-		return err
-	} else if prefix == nil {
-		return errors.New("backup compactions does not supported non-elided keys")
-	}
-	trimmedStart := storage.MVCCKey{Key: bytes.TrimPrefix(entry.Span.Key, prefix)}
-	trimmedEnd := storage.MVCCKey{Key: bytes.TrimPrefix(entry.Span.EndKey, prefix)}
-	if err := sink.Reset(ctx, entry.Span); err != nil {
-		return err
-	}
-	scratch := make([]byte, 0, len(prefix))
-	scratch = append(scratch, prefix...)
-	iter := sstIter.iter
-	for iter.SeekGE(trimmedStart); ; iter.NextKey() {
-		var key storage.MVCCKey
-		if ok, err := iter.Valid(); err != nil {
-			return err
-		} else if !ok {
-			break
-		}
-		key = iter.UnsafeKey()
-		if !key.Less(trimmedEnd) {
-			break
-		}
-		value, err := iter.UnsafeValue()
-		if err != nil {
-			return err
-		}
-		// The sst sink requires full keys including their prefix, so for every
-		// key, we need to prepend the prefix to the key. To avoid unnecessary
-		// allocations, we reuse the scratch buffer to build the full key.
-		scratch = append(scratch[:len(prefix)], key.Key...)
-		key.Key = scratch
-		if err := sink.WriteKey(ctx, key, value); err != nil {
-			return err
-		}
-	}
-	sink.AssumeNotMidRow()
-	return nil
-}
-
-func openSSTs(
-	ctx context.Context,
-	execCtx sql.JobExecContext,
-	entry execinfrapb.RestoreSpanEntry,
-	encryptionOptions *kvpb.FileEncryptionOptions,
-	details jobspb.BackupDetails,
-) (mergedSST, error) {
-	var dirs []cloud.ExternalStorage
-	storeFiles := make([]storageccl.StoreFile, 0, len(entry.Files))
-	for idx := 0; idx < len(entry.Files); idx++ {
-		file := entry.Files[idx]
-		dir, err := execCtx.ExecCfg().DistSQLSrv.ExternalStorage(ctx, file.Dir)
-		if err != nil {
-			return mergedSST{}, err
-		}
-		dirs = append(dirs, dir)
-		storeFiles = append(storeFiles, storageccl.StoreFile{Store: dir, FilePath: file.Path})
-	}
-	iterOpts := storage.IterOptions{
-		// TODO (kev-cao): Come back and update this to range keys when
-		// SSTSinkKeyWriter has been updated to support range keys.
-		KeyTypes:   storage.IterKeyTypePointsOnly,
-		LowerBound: keys.LocalMax,
-		UpperBound: keys.MaxKey,
-	}
-	iter, err := storageccl.ExternalSSTReader(ctx, storeFiles, encryptionOptions, iterOpts)
-	if err != nil {
-		return mergedSST{}, err
-	}
-	compactionIter, err := storage.NewBackupCompactionIterator(iter, details.EndTime)
-	if err != nil {
-		return mergedSST{}, err
-	}
-	return mergedSST{
-		entry: entry,
-		iter:  compactionIter,
-		cleanup: func() {
-			log.VInfof(ctx, 1, "finished with and closing %d files in span %d %v", len(entry.Files), entry.ProgressIdx, entry.Span.String())
-			compactionIter.Close()
-			for _, dir := range dirs {
-				if err := dir.Close(); err != nil {
-					log.Warningf(ctx, "close export storage failed: %v", err)
-				}
-			}
-		},
-		completeUpTo: details.EndTime,
-	}, nil
-}
-
 // updateCompactionBackupDetails takes a backup chain (up until the end timestamp)
 // and returns a corresponding BackupDetails for the compacted
 // backup of backups from the start timestamp to the end timestamp.
@@ -899,7 +639,9 @@ func getBackupChain(
 	ctx context.Context,
 	execCfg *sql.ExecutorConfig,
 	user username.SQLUsername,
-	details jobspb.BackupDetails,
+	dest jobspb.BackupDetails_Destination,
+	encryptionOpts *jobspb.BackupEncryptionOptions,
+	endTime hlc.Timestamp,
 	kmsEnv cloud.KMSEnv,
 ) (
 	[]backuppb.BackupManifest,
@@ -908,7 +650,6 @@ func getBackupChain(
 	map[int]*backupinfo.IterFactory,
 	error,
 ) {
-	dest := details.Destination
 	resolvedBaseDirs, resolvedIncDirs, _, err := resolveBackupDirs(
 		ctx, execCfg, user, dest.To, dest.IncrementalStorage, dest.Subdir,
 	)
@@ -938,18 +679,28 @@ func getBackupChain(
 			log.Warningf(ctx, "failed to cleanup incremental backup stores: %+v", err)
 		}
 	}()
-	encryption, err := backupencryption.GetEncryptionFromBaseStore(
-		ctx, baseStores[0], details.EncryptionOptions, kmsEnv,
-	)
-	if err != nil {
-		return nil, nil, nil, nil, err
+	// If encryption keys have not already been computed, then we will compute
+	// it using the base store.
+	// TODO (kev-cao): Once we resolve #143725, we can remove this check and
+	// expect `GetEncryptionFromBaseStore` to be idempotent.
+	var encryption *jobspb.BackupEncryptionOptions
+	if encryptionOpts != nil && encryptionOpts.Mode != jobspb.EncryptionMode_None &&
+		(encryptionOpts.Key != nil || encryptionOpts.KMSInfo != nil) {
+		encryption = encryptionOpts
+	} else {
+		encryption, err = backupencryption.GetEncryptionFromBaseStore(
+			ctx, baseStores[0], encryptionOpts, kmsEnv,
+		)
+		if err != nil {
+			return nil, nil, nil, nil, err
+		}
 	}
 	mem := execCfg.RootMemoryMonitor.MakeBoundAccount()
 	defer mem.Close(ctx)
 
 	_, manifests, localityInfo, memReserved, err := backupdest.ResolveBackupManifests(
 		ctx, &mem, baseStores, incStores, mkStore, resolvedBaseDirs,
-		resolvedIncDirs, details.EndTime, encryption, kmsEnv,
+		resolvedIncDirs, endTime, encryption, kmsEnv,
 		user, false,
 	)
 	if err != nil {
@@ -1002,7 +753,7 @@ func concludeBackupCompaction(
 func processProgress(
 	ctx context.Context,
 	manifest *backuppb.BackupManifest,
-	progCh <-chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+	progCh <-chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
 ) error {
 	// When a processor is done exporting a span, it will send a progress update
 	// to progCh.
@@ -1016,6 +767,7 @@ func processProgress(
 			manifest.Files = append(manifest.Files, file)
 			manifest.EntryCounts.Add(file.EntryCounts)
 		}
+		// TODO (kev-cao): Add per node progress updates.
 	}
 	return nil
 }
@@ -1046,6 +798,46 @@ func compactionJobDescription(details jobspb.BackupDetails) (string, error) {
 	return fmtCtx.CloseAndGetString(), nil
 }
 
+// doCompaction initiates and manages the entire backup compaction process.
+//
+// This function starts the compaction process, handles checkpointing and tracing
+// during the compaction, and concludes the compaction by writing the necessary
+// metadata. It coordinates the execution of the compaction plan and processes
+// progress updates to generate the backup manifest.
+func doCompaction(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	compactChain compactionChain,
+	manifest *backuppb.BackupManifest,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+) error {
+	progCh := make(chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress)
+	runDistCompaction := func(ctx context.Context) error {
+		defer close(progCh)
+		return runCompactionPlan(
+			ctx, execCtx, jobID, details, compactChain, manifest, defaultStore, kmsEnv, progCh,
+		)
+	}
+	checkpointLoop := func(ctx context.Context) error {
+		// TODO (kev-cao): Add logic for checkpointing during loop.
+		return processProgress(ctx, manifest, progCh)
+	}
+	// TODO (kev-cao): Add trace aggregator loop.
+
+	if err := ctxgroup.GoAndWait(
+		ctx, runDistCompaction, checkpointLoop,
+	); err != nil {
+		return err
+	}
+
+	return concludeBackupCompaction(
+		ctx, execCtx, defaultStore, details.EncryptionOptions, kmsEnv, manifest,
+	)
+}
+
 func init() {
 	builtins.StartCompactionJob = StartCompactionJob
 }
diff --git a/pkg/backup/backup_compaction_policy.go b/pkg/backup/compaction_policy.go
similarity index 100%
rename from pkg/backup/backup_compaction_policy.go
rename to pkg/backup/compaction_policy.go
diff --git a/pkg/backup/backup_compaction_policy_test.go b/pkg/backup/compaction_policy_test.go
similarity index 100%
rename from pkg/backup/backup_compaction_policy_test.go
rename to pkg/backup/compaction_policy_test.go
diff --git a/pkg/backup/compaction_processor.go b/pkg/backup/compaction_processor.go
new file mode 100644
index 000000000000..53a358767abf
--- /dev/null
+++ b/pkg/backup/compaction_processor.go
@@ -0,0 +1,436 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = p.runCompactBackups(ctx)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+// constructProgressProducerMeta takes the BulkProcessorProgress message and
+// converts it into a ProducerMetadata message.
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	prog.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	prog.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		p.completedSpans += progDetails.CompletedSpans
+	}
+
+	return &execinfrapb.ProducerMetadata{BulkProcessorProgress: &prog}
+}
+
+func (p *compactBackupsProcessor) close() {
+	if p.Closed {
+		return
+	}
+	if p.cancelAndWaitForWorker != nil {
+		p.cancelAndWaitForWorker()
+	}
+	p.InternalClose()
+}
+
+// ConsumerClosed is part of the RowSource interface. We have to override the
+// implementation provided by ProcessorBase.
+func (p *compactBackupsProcessor) ConsumerClosed() {
+	p.close()
+}
+
+// runCompactBackups is the main entry point for the backup compaction processor.
+func (p *compactBackupsProcessor) runCompactBackups(ctx context.Context) error {
+	if len(p.spec.AssignedSpans) == 0 {
+		return nil
+	}
+	user := p.spec.User()
+	execCfg, ok := p.FlowCtx.Cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	if !ok {
+		return errors.New("executor config is not of type sql.ExecutorConfig")
+	}
+	defaultConf, err := cloud.ExternalStorageConfFromURI(p.spec.DefaultURI, user)
+	if err != nil {
+		return errors.Wrapf(err, "export configuration")
+	}
+	defaultStore, err := execCfg.DistSQLSrv.ExternalStorage(ctx, defaultConf)
+	if err != nil {
+		return errors.Wrapf(err, "external storage")
+	}
+
+	compactChain, encryption, err := p.compactionChainFromSpec(ctx, execCfg, user)
+	if err != nil {
+		return err
+	}
+
+	backupLocalityMap, err := makeBackupLocalityMap(compactChain.compactedLocalityInfo, p.spec.User())
+	if err != nil {
+		return err
+	}
+
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, compactChain.lastBackup().EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		p.spec.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		p.spec.TargetSize,
+		p.spec.MaxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	genSpan := func(ctx context.Context, entryCh chan execinfrapb.RestoreSpanEntry) error {
+		return errors.Wrapf(generateAndSendImportSpans(
+			ctx,
+			p.spec.Spans,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			entryCh,
+		), "generate and send import spans")
+	}
+
+	entryCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	tasks := []func(context.Context) error{
+		func(ctx context.Context) error {
+			defer close(entryCh)
+			return genSpan(ctx, entryCh)
+		},
+		func(ctx context.Context) error {
+			return p.processSpanEntries(
+				ctx, execCfg, entryCh, encryption, defaultStore,
+			)
+		},
+	}
+
+	return ctxgroup.GoAndWait(ctx, tasks...)
+}
+
+// processSpanEntries pulls RestoreSpanEntries from entryCh and if they are
+// assigned to the processor, performs compaction on them.
+func (p *compactBackupsProcessor) processSpanEntries(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	entryCh chan execinfrapb.RestoreSpanEntry,
+	encryption *jobspb.BackupEncryptionOptions,
+	store cloud.ExternalStorage,
+) error {
+	var fileEncryption *kvpb.FileEncryptionOptions
+	if encryption != nil {
+		fileEncryption = &kvpb.FileEncryptionOptions{Key: encryption.Key}
+	}
+	sinkConf := backupsink.SSTSinkConf{
+		ID:        execCfg.DistSQLSrv.NodeID.SQLInstanceID(),
+		Enc:       fileEncryption,
+		ProgCh:    p.progCh,
+		Settings:  &execCfg.Settings.SV,
+		ElideMode: p.spec.ElideMode,
+	}
+	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := sink.Flush(ctx); err != nil {
+			log.Warningf(ctx, "failed to flush sink: %v", err)
+			logClose(ctx, sink, "SST sink")
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case entry, ok := <-entryCh:
+			if !ok {
+				return nil
+			}
+			if assigned, err := p.isAssignedEntry(entry); err != nil {
+				return err
+			} else if !assigned {
+				continue
+			}
+			sstIter, err := openSSTs(ctx, execCfg, entry, fileEncryption, p.spec.EndTime)
+			if err != nil {
+				return errors.Wrap(err, "opening SSTs")
+			}
+
+			if err := compactSpanEntry(ctx, sstIter, sink); err != nil {
+				return errors.Wrap(err, "compacting span entry")
+			}
+		}
+	}
+}
+
+// isAssignedEntry checks if a RestoreSpanEntry is assigned to be compacted by
+// this processor. We expect that if an entry is assigned to a processor, it is
+// to be assigned in its entirety. Otherwise, an error is returned.
+func (p *compactBackupsProcessor) isAssignedEntry(
+	entry execinfrapb.RestoreSpanEntry,
+) (bool, error) {
+	for _, assignedSpan := range p.spec.AssignedSpans {
+		if assignedSpan.Contains(entry.Span) {
+			return true, nil
+		} else if assignedSpan.Overlaps(entry.Span) {
+			// If a RestoreSpanEntry overlaps with an assigned span but is not contained
+			// within it, that implies that multiple processors will attempt to compact
+			// the restore entry. This is an error as it will result in some conflicting
+			// span to SST file mappings in the manifest.
+			return false, errors.AssertionFailedf(
+				"assigned span %v overlaps with entry span %v but does not contain it",
+				assignedSpan, entry.Span,
+			)
+		}
+	}
+	return false, nil
+}
+
+// compactionChainFromSpec constructs a compactionChain for the spec of a processor.
+func (p *compactBackupsProcessor) compactionChainFromSpec(
+	ctx context.Context, execCfg *sql.ExecutorConfig, user username.SQLUsername,
+) (compactionChain, *jobspb.BackupEncryptionOptions, error) {
+	kmsEnv := backupencryption.MakeBackupKMSEnv(
+		execCfg.Settings,
+		&execCfg.ExternalIODirConfig,
+		execCfg.InternalDB,
+		user,
+	)
+	prevManifests, localityInfo, encryption, allIters, err := getBackupChain(
+		ctx, execCfg, user, p.spec.Destination, p.spec.Encryption, p.spec.EndTime, &kmsEnv,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	compactChain, err := newCompactionChain(
+		prevManifests, p.spec.StartTime, p.spec.EndTime, localityInfo, allIters,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	return compactChain, encryption, nil
+}
+
+func openSSTs(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	entry execinfrapb.RestoreSpanEntry,
+	encryptionOptions *kvpb.FileEncryptionOptions,
+	endTime hlc.Timestamp,
+) (mergedSST, error) {
+	var dirs []cloud.ExternalStorage
+	storeFiles := make([]storageccl.StoreFile, 0, len(entry.Files))
+	for idx := range entry.Files {
+		file := entry.Files[idx]
+		dir, err := execCfg.DistSQLSrv.ExternalStorage(ctx, file.Dir)
+		if err != nil {
+			return mergedSST{}, err
+		}
+		dirs = append(dirs, dir)
+		storeFiles = append(storeFiles, storageccl.StoreFile{Store: dir, FilePath: file.Path})
+	}
+	iterOpts := storage.IterOptions{
+		// TODO (kev-cao): Come back and update this to range keys when
+		// SSTSinkKeyWriter has been updated to support range keys.
+		KeyTypes:   storage.IterKeyTypePointsOnly,
+		LowerBound: keys.LocalMax,
+		UpperBound: keys.MaxKey,
+	}
+	iter, err := storageccl.ExternalSSTReader(ctx, storeFiles, encryptionOptions, iterOpts)
+	if err != nil {
+		return mergedSST{}, err
+	}
+	compactionIter, err := storage.NewBackupCompactionIterator(iter, endTime)
+	if err != nil {
+		return mergedSST{}, err
+	}
+	return mergedSST{
+		entry: entry,
+		iter:  compactionIter,
+		cleanup: func() {
+			log.VInfof(ctx, 1, "finished with and closing %d files in span %d %v", len(entry.Files), entry.ProgressIdx, entry.Span.String())
+			compactionIter.Close()
+			for _, dir := range dirs {
+				if err := dir.Close(); err != nil {
+					log.Warningf(ctx, "close export storage failed: %v", err)
+				}
+			}
+		},
+		completeUpTo: endTime,
+	}, nil
+}
+
+func compactSpanEntry(
+	ctx context.Context, sstIter mergedSST, sink *backupsink.SSTSinkKeyWriter,
+) error {
+	defer sstIter.cleanup()
+	entry := sstIter.entry
+	prefix, err := backupsink.ElidedPrefix(entry.Span.Key, entry.ElidedPrefix)
+	if err != nil {
+		return err
+	} else if prefix == nil {
+		return errors.New("backup compactions does not supported non-elided keys")
+	}
+	trimmedStart := storage.MVCCKey{Key: bytes.TrimPrefix(entry.Span.Key, prefix)}
+	trimmedEnd := storage.MVCCKey{Key: bytes.TrimPrefix(entry.Span.EndKey, prefix)}
+	if err := sink.Reset(ctx, entry.Span); err != nil {
+		return err
+	}
+	scratch := make([]byte, 0, len(prefix))
+	scratch = append(scratch, prefix...)
+	iter := sstIter.iter
+	for iter.SeekGE(trimmedStart); ; iter.NextKey() {
+		var key storage.MVCCKey
+		if ok, err := iter.Valid(); err != nil {
+			return err
+		} else if !ok {
+			break
+		}
+		key = iter.UnsafeKey()
+		if !key.Less(trimmedEnd) {
+			break
+		}
+		value, err := iter.UnsafeValue()
+		if err != nil {
+			return err
+		}
+		// The sst sink requires full keys including their prefix, so for every
+		// key, we need to prepend the prefix to the key. To avoid unnecessary
+		// allocations, we reuse the scratch buffer to build the full key.
+		scratch = append(scratch[:len(prefix)], key.Key...)
+		key.Key = scratch
+		if err := sink.WriteKey(ctx, key, value); err != nil {
+			return err
+		}
+	}
+	sink.AssumeNotMidRow()
+	return nil
+}
+
+func init() {
+	rowexec.NewCompactBackupsProcessor = newCompactBackupsProcessor
+}
diff --git a/pkg/backup/backup_compaction_test.go b/pkg/backup/compaction_test.go
similarity index 100%
rename from pkg/backup/backup_compaction_test.go
rename to pkg/backup/compaction_test.go
diff --git a/pkg/roachpb/span_group.go b/pkg/roachpb/span_group.go
index 7b08a7b3da94..19fe41510e39 100644
--- a/pkg/roachpb/span_group.go
+++ b/pkg/roachpb/span_group.go
@@ -119,6 +119,14 @@ func (g *SpanGroup) ForEach(op func(span Span) error) error {
 	})
 }
 
+// Clear clears all of the spans from the SpanGroup, resetting it to be used
+// again.
+func (g *SpanGroup) Clear() {
+	if g.rg != nil {
+		g.rg.Clear()
+	}
+}
+
 // s2r converts a Span to an interval.Range.  Since the Key and
 // interval.Comparable types are both just aliases of []byte,
 // we don't have to perform any other conversion.
diff --git a/pkg/sql/execinfrapb/api.go b/pkg/sql/execinfrapb/api.go
index d5cc5055f3d0..fe6c6597cfcf 100644
--- a/pkg/sql/execinfrapb/api.go
+++ b/pkg/sql/execinfrapb/api.go
@@ -82,3 +82,8 @@ func (m *ChangeFrontierSpec) User() username.SQLUsername {
 func (m *GenerativeSplitAndScatterSpec) User() username.SQLUsername {
 	return m.UserProto.Decode()
 }
+
+// User accesses the user field.
+func (m *CompactBackupsSpec) User() username.SQLUsername {
+	return m.UserProto.Decode()
+}
diff --git a/pkg/sql/execinfrapb/flow_diagram.go b/pkg/sql/execinfrapb/flow_diagram.go
index 60b55e887268..602a7abe78f4 100644
--- a/pkg/sql/execinfrapb/flow_diagram.go
+++ b/pkg/sql/execinfrapb/flow_diagram.go
@@ -771,6 +771,29 @@ func (i *IngestStoppedSpec) summary() (string, []string) {
 	return "IngestStoppedSpec", []string{detail}
 }
 
+// summary implements the diagramCellType interface.
+func (m *CompactBackupsSpec) summary() (string, []string) {
+	var spanStr strings.Builder
+	if len(m.Spans) > 0 {
+		spanStr.WriteString(fmt.Sprintf("Spans [%d]: ", len(m.AssignedSpans)))
+		const limit = 3
+		for i := 0; i < len(m.AssignedSpans) && i < limit; i++ {
+			if i > 0 {
+				spanStr.WriteString(", ")
+			}
+			spanStr.WriteString(m.AssignedSpans[i].String())
+		}
+		if len(m.Spans) > limit {
+			spanStr.WriteString("...")
+		}
+	}
+
+	details := []string{
+		spanStr.String(),
+	}
+	return "CompactBackupsSpec", details
+}
+
 type diagramCell struct {
 	Title   string   `json:"title"`
 	Details []string `json:"details"`
diff --git a/pkg/sql/execinfrapb/processors.proto b/pkg/sql/execinfrapb/processors.proto
index d84e49023437..72aaafb1d09f 100644
--- a/pkg/sql/execinfrapb/processors.proto
+++ b/pkg/sql/execinfrapb/processors.proto
@@ -125,9 +125,10 @@ message ProcessorCoreUnion {
   optional LogicalReplicationOfflineScanSpec logicalReplicationOfflineScan = 46;
   optional VectorSearchSpec vectorSearch = 47;
   optional VectorMutationSearchSpec vectorMutationSearch = 48;
+  optional CompactBackupsSpec compactBackups = 49;
 
   reserved 6, 12, 14, 17, 18, 19, 20, 32;
-  // NEXT ID: 49.
+  // NEXT ID: 50.
 }
 
 // NoopCoreSpec indicates a "no-op" processor core. This is used when we just
diff --git a/pkg/sql/execinfrapb/processors_bulk_io.proto b/pkg/sql/execinfrapb/processors_bulk_io.proto
index a31bb87471dc..30d2d89591b5 100644
--- a/pkg/sql/execinfrapb/processors_bulk_io.proto
+++ b/pkg/sql/execinfrapb/processors_bulk_io.proto
@@ -569,3 +569,31 @@ message LogicalReplicationOfflineScanSpec {
     optional string metrics_label = 8 [(gogoproto.nullable) = false];
 
 }
+
+message CompactBackupsSpec {
+  optional int64 job_id = 1 [(gogoproto.nullable) = false, (gogoproto.customname) = "JobID"];
+  optional string default_uri = 2 [(gogoproto.nullable) = false, (gogoproto.customname) = "DefaultURI"];
+  optional jobs.jobspb.BackupDetails.Destination destination = 3 [(gogoproto.nullable) = false];
+  optional jobs.jobspb.BackupEncryptionOptions encryption = 4;
+  optional util.hlc.Timestamp startTime = 5 [(gogoproto.nullable) = false];
+  optional util.hlc.Timestamp endTime = 6 [(gogoproto.nullable) = false];
+  optional ElidePrefix elide_mode = 7 [(gogoproto.nullable) = false];
+  // spans represents the entire set of spans to be compacted. We send all of the
+	// spans to all processors because each processor needs to be able to call
+	// generateAndSendImportSpans to then cross check the generated spans against
+	// the assigned spans. We are uncertain if generateAndSendImportSpans will work
+	// if it is only provided the assigned spans, so to be safe we send the entire
+	// set of spans.
+  repeated roachpb.Span spans = 8 [(gogoproto.nullable) = false];
+  // assigned_spans represents the spans assigned to this particular processor 
+  // to compact.
+  repeated roachpb.Span assigned_spans = 9 [(gogoproto.nullable) = false];
+  optional string user_proto = 10 [(gogoproto.nullable) = false, (gogoproto.casttype) = "github.com/cockroachdb/cockroach/pkg/security/username.SQLUsernameProto"];
+	// Since compaction processors each have to run generateAndSendImportSpans,
+	// both target_size and max_files are sent through to the spec to ensure
+	// consistent values are used in the event those cluster settings are changed
+	// before the processors can load them.
+	optional int64 target_size = 11 [(gogoproto.nullable) = false];
+	optional int64 max_files = 12 [(gogoproto.nullable) = false];
+	// NEXT ID: 13.
+}
diff --git a/pkg/sql/rowexec/processors.go b/pkg/sql/rowexec/processors.go
index d94fe5146316..2e57364faed0 100644
--- a/pkg/sql/rowexec/processors.go
+++ b/pkg/sql/rowexec/processors.go
@@ -405,6 +405,15 @@ func NewProcessor(
 		}
 		return NewGenerativeSplitAndScatterProcessor(ctx, flowCtx, processorID, *core.GenerativeSplitAndScatter, post)
 	}
+	if core.CompactBackups != nil {
+		if err := checkNumIn(inputs, 0); err != nil {
+			return nil, err
+		}
+		if NewCompactBackupsProcessor == nil {
+			return nil, errors.New("CompactBackups processor unimplemented")
+		}
+		return NewCompactBackupsProcessor(ctx, flowCtx, processorID, *core.CompactBackups, post)
+	}
 	return nil, errors.Errorf("unsupported processor core %q", core)
 }
 
@@ -450,3 +459,5 @@ var NewGenerativeSplitAndScatterProcessor func(context.Context, *execinfra.FlowC
 var NewLogicalReplicationWriterProcessor func(context.Context, *execinfra.FlowCtx, int32, execinfrapb.LogicalReplicationWriterSpec, *execinfrapb.PostProcessSpec) (execinfra.Processor, error)
 
 var NewLogicalReplicationOfflineScanProcessor func(context.Context, *execinfra.FlowCtx, int32, execinfrapb.LogicalReplicationOfflineScanSpec, *execinfrapb.PostProcessSpec) (execinfra.Processor, error)
+
+var NewCompactBackupsProcessor func(context.Context, *execinfra.FlowCtx, int32, execinfrapb.CompactBackupsSpec, *execinfrapb.PostProcessSpec) (execinfra.Processor, error)


Reviews:
user: login: yuzefovich
body: Nice! I only skimmed the PR to look at the distsql bits and left some questions / comments along the way. It doesn't look like a more involved review from Queries is needed, so I'll defer to DR folks.

> Reviewed 6 of 12 files at r1, all commit messages.
> *[Reviewable](https://reviewable.io/reviews/cockroachdb/cockroach/143769#-:-OMii75oDatk6lQzxTZr:b-s15lxp)* status: <span>:shipit:</span> complete! 0 of 0 LGTMs obtained (waiting on @jeffswenson and @msbutler)

___
*[`pkg/sql/execinfrapb/processors_bulk_io.proto` line 581 at r1](https://reviewable.io/reviews/cockroachdb/cockroach/143769#-OMihvtf4lmL2u0pt302:-OMihvtgAA3gACxOkqXv:b-9u9004) ([raw file](https://github.com/cockroachdb/cockroach/blob/eb904397acfd949cb82ead95db28d49f42fbf879/pkg/sql/execinfrapb/processors_bulk_io.proto#L581)):*
> ```proto
>   optional util.hlc.Timestamp endTime = 6 [(gogoproto.nullable) = false];
>   optional ElidePrefix elide_mode = 7 [(gogoproto.nullable) = false];
>   // spans represents the entire set of spans to be compacted.
> ```

nit: consider expanding the comment to indicate why a particular processor would want to have access to the entire set of spans (i.e. why we're including this information into the spec at all).
___
*[`pkg/backup/compaction_processor_planning.go` line 48 at r1](https://reviewable.io/reviews/cockroachdb/cockroach/143769#-OMigiRr2padav6CQ7bx:-OMigiRr2padav6CQ7by:b6p54gv) ([raw file](https://github.com/cockroachdb/cockroach/blob/eb904397acfd949cb82ead95db28d49f42fbf879/pkg/backup/compaction_processor_planning.go#L48)):*
> ```Go
> 	progCh chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
> ) error {
> 	defer close(progCh)
> ```

nit: the comment says it's the caller's responsibility to close this channel. Is the comment stale?
___
*[`pkg/backup/compaction_processor_planning.go` line 158 at r1](https://reviewable.io/reviews/cockroachdb/cockroach/143769#-OMihF_58R9v7VjSRSTD:-OMihF_58R9v7VjSRSTE:b-f4zej7) ([raw file](https://github.com/cockroachdb/cockroach/blob/eb904397acfd949cb82ead95db28d49f42fbf879/pkg/backup/compaction_processor_planning.go#L158)):*
> ```Go
> 	planCtx, sqlInstanceIDs, err := dsp.SetupAllNodesPlanningWithOracle(
> 		ctx, execCtx.ExtendedEvalContext(), execCtx.ExecCfg(),
> 		physicalplan.DefaultReplicaChooser, roachpb.Locality{},
> ```

nit: do we not want to provide the execution locality?
___
*[`pkg/backup/compaction_processor_planning.go` line 243 at r1](https://reviewable.io/reviews/cockroachdb/cockroach/143769#-OMihX8I5jw8MpGLT7xc:-OMihX8J7hlJTbwIGj4l:buoaf6x) ([raw file](https://github.com/cockroachdb/cockroach/blob/eb904397acfd949cb82ead95db28d49f42fbf879/pkg/backup/compaction_processor_planning.go#L243)):*
> ```Go
> 	}
> 
> 	spanEntryCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
> ```

nit: interesting choice of the size of the channel buffer? What's the rationale for this number? Consider leaving a comment to explain it.


<!-- Sent from Reviewable.io -->

state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T00:21:41Z
user: login: kev-cao
body: 

> *[Reviewable](https://reviewable.io/reviews/cockroachdb/cockroach/143769)* status: <span>:shipit:</span> complete! 0 of 0 LGTMs obtained (waiting on @dt, @jeffswenson, @msbutler, and @yuzefovich)

___
*[`pkg/backup/compaction_processor_planning.go` line 48 at r1](https://reviewable.io/reviews/cockroachdb/cockroach/143769#-OMigiRr2padav6CQ7bx:-OMlisSX-aGNZ15xfXDh:b3fyjat) ([raw file](https://github.com/cockroachdb/cockroach/blob/eb904397acfd949cb82ead95db28d49f42fbf879/pkg/backup/compaction_processor_planning.go#L48)):*
<details><summary><i>Previously, yuzefovich (Yahor Yuzefovich) wrote…</i></summary><blockquote>

nit: the comment says it's the caller's responsibility to close this channel. Is the comment stale?
</blockquote></details>

Oh actually it's that line of code that's stale haha, thanks for the catch
___
*[`pkg/backup/compaction_processor_planning.go` line 158 at r1](https://reviewable.io/reviews/cockroachdb/cockroach/143769#-OMihF_58R9v7VjSRSTD:-OMliySkBUoWdwItsCre:bc828fa) ([raw file](https://github.com/cockroachdb/cockroach/blob/eb904397acfd949cb82ead95db28d49f42fbf879/pkg/backup/compaction_processor_planning.go#L158)):*
<details><summary><i>Previously, yuzefovich (Yahor Yuzefovich) wrote…</i></summary><blockquote>

nit: do we not want to provide the execution locality?
</blockquote></details>

That's something we will add in a separate PR — there's still work to be done to have compaction support all of the options of `BACKUP`.
___
*[`pkg/backup/compaction_processor_planning.go` line 243 at r1](https://reviewable.io/reviews/cockroachdb/cockroach/143769#-OMihX8I5jw8MpGLT7xc:-OMljBGYEdLJjAqAuuhw:b-8hvi69) ([raw file](https://github.com/cockroachdb/cockroach/blob/eb904397acfd949cb82ead95db28d49f42fbf879/pkg/backup/compaction_processor_planning.go#L243)):*
<details><summary><i>Previously, yuzefovich (Yahor Yuzefovich) wrote…</i></summary><blockquote>

nit: interesting choice of the size of the channel buffer? What's the rationale for this number? Consider leaving a comment to explain it.
</blockquote></details>

It's a size that we use in restore, although I don't know if there's a specific rationale for this number. @msbutler @dt do you guys know if there's any empirical reason for this number?
___
*[`pkg/sql/execinfrapb/processors_bulk_io.proto` line 581 at r1](https://reviewable.io/reviews/cockroachdb/cockroach/143769#-OMihvtf4lmL2u0pt302:-OMljST62sdEt6LLpkAp:b-d0cxol) ([raw file](https://github.com/cockroachdb/cockroach/blob/eb904397acfd949cb82ead95db28d49f42fbf879/pkg/sql/execinfrapb/processors_bulk_io.proto#L581)):*
<details><summary><i>Previously, yuzefovich (Yahor Yuzefovich) wrote…</i></summary><blockquote>

nit: consider expanding the comment to indicate why a particular processor would want to have access to the entire set of spans (i.e. why we're including this information into the spec at all).
</blockquote></details>

Yeah that sounds good, will update


<!-- Sent from Reviewable.io -->

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T14:25:21Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T16:06:15Z
user: login: msbutler
body: Really really awesome work here! there's a steep learning curve with this distsql stuff. I have yet to answer your questions or look at your tests. 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T16:41:53Z
user: login: msbutler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T16:43:15Z
user: login: msbutler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T16:44:52Z
user: login: msbutler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T16:45:14Z
user: login: msbutler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T16:49:47Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T17:14:44Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T17:16:07Z
user: login: dt
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T17:26:22Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T17:53:13Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T18:31:48Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T19:07:49Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T19:30:08Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T20:46:07Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T23:43:52Z
user: login: msbutler
body: Nearly there!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T14:56:16Z
user: login: msbutler
body: er, my nits are non blocking.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-03T14:56:56Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T19:16:09Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T19:20:39Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T19:23:49Z
user: login: kev-cao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T19:33:43Z

Review comments:
diff_hunk: @@ -0,0 +1,329 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
path: pkg/backup/compaction_processor_planning.go
user: login: msbutler
body: nit: i'd call this file `compaction_dist`, as several funcs in this file run the actual compaction. LDR and PCR use this convention. restore is silly.
created_at: 2025-04-01T15:43:45Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,329 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupinfo"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobsprofiler"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/physicalplan"
+	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/util"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/errors"
+)
+
+// runCompactionPlan creates and runs a distsql plan to compact the spans
+// in the backup chain that need to be compacted. It sends updates from the
+// BulkProcessor to the provided progress channel. It is the caller's
+// responsibility to close the progress channel.
+//
+//lint:ignore U1000 unused
path: pkg/backup/compaction_processor_planning.go
user: login: msbutler
body: you can remove the lint ignore in the second commit
created_at: 2025-04-01T15:48:46Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
path: pkg/backup/compaction_processor.go
user: login: kev-cao
body: I noticed we did this in `backup_processor.go`, but it seems unnecessary? The value passed to `constructProgressProducerMeta` isn't a pointer, so we could just used the parameter itself.
created_at: 2025-04-01T16:00:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
path: pkg/backup/compaction_processor.go
user: login: kev-cao
body: Curious why we use a map here — can you have more than one processor running per node?
created_at: 2025-04-01T16:01:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,329 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupinfo"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobsprofiler"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/physicalplan"
+	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/util"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/errors"
+)
+
+// runCompactionPlan creates and runs a distsql plan to compact the spans
+// in the backup chain that need to be compacted. It sends updates from the
+// BulkProcessor to the provided progress channel. It is the caller's
+// responsibility to close the progress channel.
+//
+//lint:ignore U1000 unused
+func runCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	compactChain compactionChain,
+	manifest *backuppb.BackupManifest,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+	progCh chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	log.Infof(
+		ctx, "planning compaction of %d backups: %s",
+		len(compactChain.chainToCompact),
+		util.Map(compactChain.chainToCompact, func(m backuppb.BackupManifest) string {
+			return m.ID.String()
+		}),
+	)
+	backupLocalityMap, err := makeBackupLocalityMap(
+		compactChain.compactedLocalityInfo, execCtx.User(),
+	)
+	if err != nil {
+		return err
+	}
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, manifest.EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+	targetSize := targetRestoreSpanSize.Get(&execCtx.ExecCfg().Settings.SV)
+	maxFiles := maxFileCount.Get(&execCtx.ExecCfg().Settings.SV)
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		manifest.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	spansToCompact, err := getSpansToCompact(
+		ctx, execCtx, manifest, compactChain.chainToCompact, details, defaultStore, kmsEnv,
+	)
+	if err != nil {
+		return err
+	}
+	genSpan := func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error {
+		return errors.Wrap(generateAndSendImportSpans(
+			ctx,
+			spansToCompact,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			spanCh,
+		), "generateAndSendImportSpans")
+	}
+	dsp := execCtx.DistSQLPlanner()
+	plan, planCtx, err := createCompactionPlan(
+		ctx, execCtx, jobID, details, manifest, dsp, genSpan, spansToCompact,
+	)
+	if err != nil {
+		return errors.Wrap(err, "creating compaction plan")
+	}
+	sql.FinalizePlan(ctx, planCtx, plan)
+
+	metaFn := func(_ context.Context, meta *execinfrapb.ProducerMetadata) error {
+		if meta.BulkProcessorProgress != nil {
+			progCh <- meta.BulkProcessorProgress
+		}
+
+		// TODO (kev-cao): Add channel for tracing aggregator events.
+		return nil
+	}
+	rowResultWriter := sql.NewRowResultWriter(nil)
+	recv := sql.MakeDistSQLReceiver(
+		ctx,
+		sql.NewMetadataCallbackWriter(rowResultWriter, metaFn),
+		tree.Rows,
+		nil, /* rangeCache */
+		nil, /* txn */
+		nil, /* clockUpdater */
+		execCtx.ExtendedEvalContext().Tracing,
+	)
+	defer recv.Release()
+
+	jobsprofiler.StorePlanDiagram(
+		ctx, execCtx.ExecCfg().DistSQLSrv.Stopper, plan, execCtx.ExecCfg().InternalDB, jobID,
+	)
+
+	evalCtxCopy := execCtx.ExtendedEvalContext().Copy()
+	dsp.Run(ctx, planCtx, nil /* txn */, plan, recv, evalCtxCopy, nil /* finishedSetupFn */)
+	return nil
+}
+
+// createCompactionPlan creates an un-finalized physical plan that will
+// distribute spans from a generator across the cluster for compaction.
+func createCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	manifest *backuppb.BackupManifest,
+	dsp *sql.DistSQLPlanner,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+) (*sql.PhysicalPlan, *sql.PlanningCtx, error) {
+	numEntries, err := countRestoreSpanEntries(ctx, genSpan)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "counting number of restore span entries")
+	}
+
+	planCtx, sqlInstanceIDs, err := dsp.SetupAllNodesPlanningWithOracle(
path: pkg/backup/compaction_processor_planning.go
user: login: msbutler
body: leave a todo around supporting exec locality?
created_at: 2025-04-01T16:03:46Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
+		}
+	}
+
+	return &execinfrapb.ProducerMetadata{BulkProcessorProgress: &progCopy}
+}
+
+func (p *compactBackupsProcessor) close() {
+	if p.Closed {
+		return
+	}
+	if p.cancelAndWaitForWorker != nil {
+		p.cancelAndWaitForWorker()
+	}
+	p.InternalClose()
+}
+
+// ConsumerClosed is part of the RowSource interface. We have to override the
+// implementation provided by ProcessorBase.
+func (p *compactBackupsProcessor) ConsumerClosed() {
+	p.close()
+}
+
+func runCompactBackups(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	spec execinfrapb.CompactBackupsSpec,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	if len(spec.AssignedSpans) == 0 {
+		return nil
+	}
+	user := spec.User()
+	execCfg, ok := flowCtx.Cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	if !ok {
+		return errors.New("executor config is not of type sql.ExecutorConfig")
+	}
+	defaultConf, err := cloud.ExternalStorageConfFromURI(spec.DefaultURI, user)
+	if err != nil {
+		return errors.Wrapf(err, "export configuration")
+	}
+	defaultStore, err := execCfg.DistSQLSrv.ExternalStorage(ctx, defaultConf)
+	if err != nil {
+		return errors.Wrapf(err, "external storage")
+	}
+
+	compactChain, encryption, err := compactionChainFromSpec(ctx, execCfg, spec, user)
+	if err != nil {
+		return err
+	}
+
+	intersectSpanCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	tasks := []func(context.Context) error{
+		func(ctx context.Context) error {
+			defer close(intersectSpanCh)
+			return genIntersectingSpansForCompaction(ctx, execCfg, spec, compactChain, intersectSpanCh)
+		},
+		func(ctx context.Context) error {
+			return processIntersectingSpanEntries(
+				ctx, execCfg, spec, intersectSpanCh, encryption, progCh, defaultStore,
+			)
+		},
+	}
+
+	return ctxgroup.GoAndWait(ctx, tasks...)
+}
+
+func processIntersectingSpanEntries(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	entryCh chan execinfrapb.RestoreSpanEntry,
+	encryption *jobspb.BackupEncryptionOptions,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+	store cloud.ExternalStorage,
+) error {
+	var fileEncryption *kvpb.FileEncryptionOptions
+	if encryption != nil {
+		fileEncryption = &kvpb.FileEncryptionOptions{Key: encryption.Key}
+	}
+	sinkConf := backupsink.SSTSinkConf{
+		ID:        execCfg.DistSQLSrv.NodeID.SQLInstanceID(),
+		Enc:       fileEncryption,
+		ProgCh:    progCh,
+		Settings:  &execCfg.Settings.SV,
+		ElideMode: spec.ElideMode,
+	}
+	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := sink.Flush(ctx); err != nil {
+			log.Warningf(ctx, "failed to flush sink: %v", err)
+			logClose(ctx, sink, "SST sink")
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case entry, ok := <-entryCh:
+			if !ok {
+				return nil
+			}
+
+			sstIter, err := openSSTs(ctx, execCfg, entry, fileEncryption, spec.EndTime)
+			if err != nil {
+				return errors.Wrap(err, "opening SSTs")
+			}
+
+			if err := compactSpanEntry(ctx, sstIter, sink); err != nil {
+				return errors.Wrap(err, "compacting span entry")
+			}
+		}
+	}
+}
+
+func compactionChainFromSpec(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	user username.SQLUsername,
+) (compactionChain, *jobspb.BackupEncryptionOptions, error) {
+	kmsEnv := backupencryption.MakeBackupKMSEnv(
+		execCfg.Settings,
+		&execCfg.ExternalIODirConfig,
+		execCfg.InternalDB,
+		user,
+	)
+	prevManifests, localityInfo, encryption, allIters, err := getBackupChain(
+		ctx, execCfg, user, spec.Destination, spec.Encryption, spec.EndTime, &kmsEnv,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	compactChain, err := newCompactionChain(
+		prevManifests, spec.StartTime, spec.EndTime, localityInfo, allIters,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	return compactChain, encryption, nil
+}
+
+func genIntersectingSpansForCompaction(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	compactChain compactionChain,
+	intersectSpanCh chan execinfrapb.RestoreSpanEntry,
+) error {
+	backupLocalityMap, err := makeBackupLocalityMap(compactChain.compactedLocalityInfo, spec.User())
+	if err != nil {
+		return err
+	}
+
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, compactChain.lastBackup().EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+
+	targetSize := targetRestoreSpanSize.Get(&execCfg.Settings.SV)
+	maxFiles := maxFileCount.Get(&execCfg.Settings.SV)
+
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		spec.Spans,
path: pkg/backup/compaction_processor.go
user: login: kev-cao
body: A slight difference here is that in planning, the span covering filter is generated from `manifest.Spans`, whereas `spec.Spans` has had the completed spans removed. The tests didn't seem to have an issue with this, although not sure if that is a problem?
created_at: 2025-04-01T16:06:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,329 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupinfo"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobsprofiler"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/physicalplan"
+	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/util"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/errors"
+)
+
+// runCompactionPlan creates and runs a distsql plan to compact the spans
+// in the backup chain that need to be compacted. It sends updates from the
+// BulkProcessor to the provided progress channel. It is the caller's
+// responsibility to close the progress channel.
+//
+//lint:ignore U1000 unused
+func runCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	compactChain compactionChain,
+	manifest *backuppb.BackupManifest,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+	progCh chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	log.Infof(
+		ctx, "planning compaction of %d backups: %s",
+		len(compactChain.chainToCompact),
+		util.Map(compactChain.chainToCompact, func(m backuppb.BackupManifest) string {
+			return m.ID.String()
+		}),
+	)
+	backupLocalityMap, err := makeBackupLocalityMap(
+		compactChain.compactedLocalityInfo, execCtx.User(),
+	)
+	if err != nil {
+		return err
+	}
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, manifest.EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+	targetSize := targetRestoreSpanSize.Get(&execCtx.ExecCfg().Settings.SV)
+	maxFiles := maxFileCount.Get(&execCtx.ExecCfg().Settings.SV)
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		manifest.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	spansToCompact, err := getSpansToCompact(
+		ctx, execCtx, manifest, compactChain.chainToCompact, details, defaultStore, kmsEnv,
+	)
+	if err != nil {
+		return err
+	}
+	genSpan := func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error {
+		return errors.Wrap(generateAndSendImportSpans(
+			ctx,
+			spansToCompact,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			spanCh,
+		), "generateAndSendImportSpans")
+	}
+	dsp := execCtx.DistSQLPlanner()
+	plan, planCtx, err := createCompactionPlan(
+		ctx, execCtx, jobID, details, manifest, dsp, genSpan, spansToCompact,
+	)
+	if err != nil {
+		return errors.Wrap(err, "creating compaction plan")
+	}
+	sql.FinalizePlan(ctx, planCtx, plan)
+
+	metaFn := func(_ context.Context, meta *execinfrapb.ProducerMetadata) error {
+		if meta.BulkProcessorProgress != nil {
+			progCh <- meta.BulkProcessorProgress
+		}
+
+		// TODO (kev-cao): Add channel for tracing aggregator events.
+		return nil
+	}
+	rowResultWriter := sql.NewRowResultWriter(nil)
+	recv := sql.MakeDistSQLReceiver(
+		ctx,
+		sql.NewMetadataCallbackWriter(rowResultWriter, metaFn),
+		tree.Rows,
+		nil, /* rangeCache */
+		nil, /* txn */
+		nil, /* clockUpdater */
+		execCtx.ExtendedEvalContext().Tracing,
+	)
+	defer recv.Release()
+
+	jobsprofiler.StorePlanDiagram(
+		ctx, execCtx.ExecCfg().DistSQLSrv.Stopper, plan, execCtx.ExecCfg().InternalDB, jobID,
+	)
+
+	evalCtxCopy := execCtx.ExtendedEvalContext().Copy()
+	dsp.Run(ctx, planCtx, nil /* txn */, plan, recv, evalCtxCopy, nil /* finishedSetupFn */)
+	return nil
+}
+
+// createCompactionPlan creates an un-finalized physical plan that will
+// distribute spans from a generator across the cluster for compaction.
+func createCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	manifest *backuppb.BackupManifest,
+	dsp *sql.DistSQLPlanner,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+) (*sql.PhysicalPlan, *sql.PlanningCtx, error) {
+	numEntries, err := countRestoreSpanEntries(ctx, genSpan)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "counting number of restore span entries")
+	}
+
+	planCtx, sqlInstanceIDs, err := dsp.SetupAllNodesPlanningWithOracle(
+		ctx, execCtx.ExtendedEvalContext(), execCtx.ExecCfg(),
+		physicalplan.DefaultReplicaChooser, roachpb.Locality{},
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	plan := planCtx.NewPhysicalPlan()
+	corePlacements, err := createCompactionCorePlacements(
+		ctx, jobID, execCtx.User(), details, manifest.ElidedPrefix,
+		genSpan, spansToCompact, sqlInstanceIDs, numEntries,
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+	plan.AddNoInputStage(
+		corePlacements,
+		execinfrapb.PostProcessSpec{},
+		[]*types.T{},
+		execinfrapb.Ordering{},
+		nil, /* finalizeLastStageCb */
+	)
+	return plan, planCtx, nil
+}
+
+// countRestoreSpanEntries counts the number of restore span entries that will be
+// be delivered by the given generator.
+func countRestoreSpanEntries(
+	ctx context.Context,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+) (int, error) {
+	countSpansCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var numImportSpans int
+	countTasks := []func(ctx context.Context) error{
+		func(ctx context.Context) error {
+			for range countSpansCh {
+				numImportSpans++
+			}
+			return nil
+		},
+		func(ctx context.Context) error {
+			defer close(countSpansCh)
+			return genSpan(ctx, countSpansCh)
+		},
+	}
+	if err := ctxgroup.GoAndWait(ctx, countTasks...); err != nil {
+		return 0, errors.Wrapf(err, "counting number of spans to compact")
+	}
+	return numImportSpans, nil
+}
+
+// createCompactionCorePlacements takes spans from a generator and evenly
+// distributes them across nodes in the cluster, returning the core placements
+// reflecting that distribution.
+//
+// TODO (kev-cao): This currently distributes the spans so that each node gets
+// roughly the same number of entries. This could be further improved to take
+// into consideration the size of the spans.
+func createCompactionCorePlacements(
+	ctx context.Context,
+	jobID jobspb.JobID,
+	user username.SQLUsername,
+	details jobspb.BackupDetails,
+	elideMode execinfrapb.ElidePrefix,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+	sqlInstanceIDs []base.SQLInstanceID,
+	numEntries int,
+) ([]physicalplan.ProcessorCorePlacement, error) {
+	numNodes := len(sqlInstanceIDs)
+	corePlacements := make([]physicalplan.ProcessorCorePlacement, numNodes)
+	for i := range corePlacements {
+		corePlacements[i].SQLInstanceID = sqlInstanceIDs[i]
+		corePlacements[i].Core.CompactBackups = &execinfrapb.CompactBackupsSpec{
+			JobID:       int64(jobID),
+			DefaultURI:  details.URI,
+			Destination: details.Destination,
+			Encryption:  details.EncryptionOptions,
+			StartTime:   details.StartTime,
+			EndTime:     details.EndTime,
+			ElideMode:   elideMode,
+			UserProto:   user.EncodeProto(),
+			Spans:       spansToCompact,
+		}
+	}
+
+	spanEntryCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var tasks []func(ctx context.Context) error
+	tasks = append(tasks, func(ctx context.Context) error {
+		defer close(spanEntryCh)
+		return genSpan(ctx, spanEntryCh)
+	})
+	tasks = append(tasks, func(ctx context.Context) error {
+		numEntriesPerNode := numEntries / numNodes
+		leftoverEntries := numEntries % numNodes
+		getTargetNumEntries := func(nodeIdx int) int {
+			if nodeIdx < leftoverEntries {
+				// This more evenly distributes the leftover entries across the nodes
+				// after doing integer division to assign the entries to the nodes.
+				return numEntriesPerNode + 1
+			}
+			return numEntriesPerNode
+		}
+		currNode := 0
+		currEntries := make([]execinfrapb.RestoreSpanEntry, 0, numEntriesPerNode+1)
+		targetNumEntries := getTargetNumEntries(currNode)
+
+		for entry := range spanEntryCh {
+			currEntries = append(currEntries, entry)
path: pkg/backup/compaction_processor_planning.go
user: login: msbutler
body: could you do the reduce step before adding an entry to the buffer? I.e. the buffer would simply be a set of spans.
created_at: 2025-04-01T16:10:32Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,329 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupinfo"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobsprofiler"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/physicalplan"
+	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/util"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/errors"
+)
+
+// runCompactionPlan creates and runs a distsql plan to compact the spans
+// in the backup chain that need to be compacted. It sends updates from the
+// BulkProcessor to the provided progress channel. It is the caller's
+// responsibility to close the progress channel.
+//
+//lint:ignore U1000 unused
+func runCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	compactChain compactionChain,
+	manifest *backuppb.BackupManifest,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+	progCh chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	log.Infof(
+		ctx, "planning compaction of %d backups: %s",
+		len(compactChain.chainToCompact),
+		util.Map(compactChain.chainToCompact, func(m backuppb.BackupManifest) string {
+			return m.ID.String()
+		}),
+	)
+	backupLocalityMap, err := makeBackupLocalityMap(
+		compactChain.compactedLocalityInfo, execCtx.User(),
+	)
+	if err != nil {
+		return err
+	}
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, manifest.EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+	targetSize := targetRestoreSpanSize.Get(&execCtx.ExecCfg().Settings.SV)
+	maxFiles := maxFileCount.Get(&execCtx.ExecCfg().Settings.SV)
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		manifest.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	spansToCompact, err := getSpansToCompact(
+		ctx, execCtx, manifest, compactChain.chainToCompact, details, defaultStore, kmsEnv,
+	)
+	if err != nil {
+		return err
+	}
+	genSpan := func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error {
+		return errors.Wrap(generateAndSendImportSpans(
+			ctx,
+			spansToCompact,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			spanCh,
+		), "generateAndSendImportSpans")
+	}
+	dsp := execCtx.DistSQLPlanner()
+	plan, planCtx, err := createCompactionPlan(
+		ctx, execCtx, jobID, details, manifest, dsp, genSpan, spansToCompact,
+	)
+	if err != nil {
+		return errors.Wrap(err, "creating compaction plan")
+	}
+	sql.FinalizePlan(ctx, planCtx, plan)
+
+	metaFn := func(_ context.Context, meta *execinfrapb.ProducerMetadata) error {
+		if meta.BulkProcessorProgress != nil {
+			progCh <- meta.BulkProcessorProgress
+		}
+
+		// TODO (kev-cao): Add channel for tracing aggregator events.
+		return nil
+	}
+	rowResultWriter := sql.NewRowResultWriter(nil)
+	recv := sql.MakeDistSQLReceiver(
+		ctx,
+		sql.NewMetadataCallbackWriter(rowResultWriter, metaFn),
+		tree.Rows,
+		nil, /* rangeCache */
+		nil, /* txn */
+		nil, /* clockUpdater */
+		execCtx.ExtendedEvalContext().Tracing,
+	)
+	defer recv.Release()
+
+	jobsprofiler.StorePlanDiagram(
+		ctx, execCtx.ExecCfg().DistSQLSrv.Stopper, plan, execCtx.ExecCfg().InternalDB, jobID,
+	)
+
+	evalCtxCopy := execCtx.ExtendedEvalContext().Copy()
+	dsp.Run(ctx, planCtx, nil /* txn */, plan, recv, evalCtxCopy, nil /* finishedSetupFn */)
+	return nil
+}
+
+// createCompactionPlan creates an un-finalized physical plan that will
+// distribute spans from a generator across the cluster for compaction.
+func createCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	manifest *backuppb.BackupManifest,
+	dsp *sql.DistSQLPlanner,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+) (*sql.PhysicalPlan, *sql.PlanningCtx, error) {
+	numEntries, err := countRestoreSpanEntries(ctx, genSpan)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "counting number of restore span entries")
+	}
+
+	planCtx, sqlInstanceIDs, err := dsp.SetupAllNodesPlanningWithOracle(
+		ctx, execCtx.ExtendedEvalContext(), execCtx.ExecCfg(),
+		physicalplan.DefaultReplicaChooser, roachpb.Locality{},
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	plan := planCtx.NewPhysicalPlan()
+	corePlacements, err := createCompactionCorePlacements(
+		ctx, jobID, execCtx.User(), details, manifest.ElidedPrefix,
+		genSpan, spansToCompact, sqlInstanceIDs, numEntries,
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+	plan.AddNoInputStage(
+		corePlacements,
+		execinfrapb.PostProcessSpec{},
+		[]*types.T{},
+		execinfrapb.Ordering{},
+		nil, /* finalizeLastStageCb */
+	)
+	return plan, planCtx, nil
+}
+
+// countRestoreSpanEntries counts the number of restore span entries that will be
+// be delivered by the given generator.
+func countRestoreSpanEntries(
+	ctx context.Context,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+) (int, error) {
+	countSpansCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var numImportSpans int
+	countTasks := []func(ctx context.Context) error{
+		func(ctx context.Context) error {
+			for range countSpansCh {
+				numImportSpans++
+			}
+			return nil
+		},
+		func(ctx context.Context) error {
+			defer close(countSpansCh)
+			return genSpan(ctx, countSpansCh)
+		},
+	}
+	if err := ctxgroup.GoAndWait(ctx, countTasks...); err != nil {
+		return 0, errors.Wrapf(err, "counting number of spans to compact")
+	}
+	return numImportSpans, nil
+}
+
+// createCompactionCorePlacements takes spans from a generator and evenly
+// distributes them across nodes in the cluster, returning the core placements
+// reflecting that distribution.
+//
+// TODO (kev-cao): This currently distributes the spans so that each node gets
+// roughly the same number of entries. This could be further improved to take
+// into consideration the size of the spans.
+func createCompactionCorePlacements(
+	ctx context.Context,
+	jobID jobspb.JobID,
+	user username.SQLUsername,
+	details jobspb.BackupDetails,
+	elideMode execinfrapb.ElidePrefix,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+	sqlInstanceIDs []base.SQLInstanceID,
+	numEntries int,
+) ([]physicalplan.ProcessorCorePlacement, error) {
+	numNodes := len(sqlInstanceIDs)
+	corePlacements := make([]physicalplan.ProcessorCorePlacement, numNodes)
+	for i := range corePlacements {
+		corePlacements[i].SQLInstanceID = sqlInstanceIDs[i]
+		corePlacements[i].Core.CompactBackups = &execinfrapb.CompactBackupsSpec{
+			JobID:       int64(jobID),
+			DefaultURI:  details.URI,
+			Destination: details.Destination,
+			Encryption:  details.EncryptionOptions,
+			StartTime:   details.StartTime,
+			EndTime:     details.EndTime,
+			ElideMode:   elideMode,
+			UserProto:   user.EncodeProto(),
+			Spans:       spansToCompact,
+		}
+	}
+
+	spanEntryCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var tasks []func(ctx context.Context) error
+	tasks = append(tasks, func(ctx context.Context) error {
+		defer close(spanEntryCh)
+		return genSpan(ctx, spanEntryCh)
+	})
+	tasks = append(tasks, func(ctx context.Context) error {
+		numEntriesPerNode := numEntries / numNodes
+		leftoverEntries := numEntries % numNodes
+		getTargetNumEntries := func(nodeIdx int) int {
+			if nodeIdx < leftoverEntries {
+				// This more evenly distributes the leftover entries across the nodes
+				// after doing integer division to assign the entries to the nodes.
+				return numEntriesPerNode + 1
+			}
+			return numEntriesPerNode
+		}
+		currNode := 0
+		currEntries := make([]execinfrapb.RestoreSpanEntry, 0, numEntriesPerNode+1)
+		targetNumEntries := getTargetNumEntries(currNode)
+
+		for entry := range spanEntryCh {
+			currEntries = append(currEntries, entry)
path: pkg/backup/compaction_processor_planning.go
user: login: msbutler
body: could you make this buffer a span group, which will automatically merge adjacent spans? when you bind the spans to `AssignedSpans`, you'll have to convert it back to a good old list of spans. 

(imagine we're sharding up a 100 tb table, we don't want to send 100s of adjacent spans in the spec-- we'd rather just send 1).
created_at: 2025-04-01T16:15:06Z
author_association: COLLABORATOR
diff_hunk: @@ -771,6 +771,29 @@ func (i *IngestStoppedSpec) summary() (string, []string) {
 	return "IngestStoppedSpec", []string{detail}
 }
 
+// summary implements the diagramCellType interface.
path: pkg/sql/execinfrapb/flow_diagram.go
user: login: msbutler
body: you're a sucker for a nice UI huh
created_at: 2025-04-01T16:19:19Z
author_association: COLLABORATOR
diff_hunk: @@ -938,18 +679,28 @@ func getBackupChain(
 			log.Warningf(ctx, "failed to cleanup incremental backup stores: %+v", err)
 		}
 	}()
-	encryption, err := backupencryption.GetEncryptionFromBaseStore(
-		ctx, baseStores[0], details.EncryptionOptions, kmsEnv,
-	)
-	if err != nil {
-		return nil, nil, nil, nil, err
+	// If encryption keys have not already been computed, then we will compute
path: pkg/backup/compaction_job.go
user: login: msbutler
body: i was planning to fix this in my diff, so no need to fix this in your diff, unless things broke when you added the distsql procs?
created_at: 2025-04-01T16:22:42Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
+		}
+	}
+
+	return &execinfrapb.ProducerMetadata{BulkProcessorProgress: &progCopy}
+}
+
+func (p *compactBackupsProcessor) close() {
+	if p.Closed {
+		return
+	}
+	if p.cancelAndWaitForWorker != nil {
+		p.cancelAndWaitForWorker()
+	}
+	p.InternalClose()
+}
+
+// ConsumerClosed is part of the RowSource interface. We have to override the
+// implementation provided by ProcessorBase.
+func (p *compactBackupsProcessor) ConsumerClosed() {
+	p.close()
+}
+
+func runCompactBackups(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	spec execinfrapb.CompactBackupsSpec,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	if len(spec.AssignedSpans) == 0 {
+		return nil
+	}
+	user := spec.User()
+	execCfg, ok := flowCtx.Cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	if !ok {
+		return errors.New("executor config is not of type sql.ExecutorConfig")
+	}
+	defaultConf, err := cloud.ExternalStorageConfFromURI(spec.DefaultURI, user)
+	if err != nil {
+		return errors.Wrapf(err, "export configuration")
+	}
+	defaultStore, err := execCfg.DistSQLSrv.ExternalStorage(ctx, defaultConf)
+	if err != nil {
+		return errors.Wrapf(err, "external storage")
+	}
+
+	compactChain, encryption, err := compactionChainFromSpec(ctx, execCfg, spec, user)
+	if err != nil {
+		return err
+	}
+
+	intersectSpanCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	tasks := []func(context.Context) error{
+		func(ctx context.Context) error {
+			defer close(intersectSpanCh)
+			return genIntersectingSpansForCompaction(ctx, execCfg, spec, compactChain, intersectSpanCh)
+		},
+		func(ctx context.Context) error {
+			return processIntersectingSpanEntries(
+				ctx, execCfg, spec, intersectSpanCh, encryption, progCh, defaultStore,
+			)
+		},
+	}
+
+	return ctxgroup.GoAndWait(ctx, tasks...)
+}
+
+func processIntersectingSpanEntries(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	entryCh chan execinfrapb.RestoreSpanEntry,
+	encryption *jobspb.BackupEncryptionOptions,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+	store cloud.ExternalStorage,
+) error {
+	var fileEncryption *kvpb.FileEncryptionOptions
+	if encryption != nil {
+		fileEncryption = &kvpb.FileEncryptionOptions{Key: encryption.Key}
+	}
+	sinkConf := backupsink.SSTSinkConf{
+		ID:        execCfg.DistSQLSrv.NodeID.SQLInstanceID(),
+		Enc:       fileEncryption,
+		ProgCh:    progCh,
+		Settings:  &execCfg.Settings.SV,
+		ElideMode: spec.ElideMode,
+	}
+	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := sink.Flush(ctx); err != nil {
+			log.Warningf(ctx, "failed to flush sink: %v", err)
+			logClose(ctx, sink, "SST sink")
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case entry, ok := <-entryCh:
+			if !ok {
+				return nil
+			}
+
+			sstIter, err := openSSTs(ctx, execCfg, entry, fileEncryption, spec.EndTime)
+			if err != nil {
+				return errors.Wrap(err, "opening SSTs")
+			}
+
+			if err := compactSpanEntry(ctx, sstIter, sink); err != nil {
+				return errors.Wrap(err, "compacting span entry")
+			}
+		}
+	}
+}
+
+func compactionChainFromSpec(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	user username.SQLUsername,
+) (compactionChain, *jobspb.BackupEncryptionOptions, error) {
+	kmsEnv := backupencryption.MakeBackupKMSEnv(
+		execCfg.Settings,
+		&execCfg.ExternalIODirConfig,
+		execCfg.InternalDB,
+		user,
+	)
+	prevManifests, localityInfo, encryption, allIters, err := getBackupChain(
+		ctx, execCfg, user, spec.Destination, spec.Encryption, spec.EndTime, &kmsEnv,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	compactChain, err := newCompactionChain(
+		prevManifests, spec.StartTime, spec.EndTime, localityInfo, allIters,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	return compactChain, encryption, nil
+}
+
+func genIntersectingSpansForCompaction(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	compactChain compactionChain,
+	intersectSpanCh chan execinfrapb.RestoreSpanEntry,
+) error {
+	backupLocalityMap, err := makeBackupLocalityMap(compactChain.compactedLocalityInfo, spec.User())
+	if err != nil {
+		return err
+	}
+
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, compactChain.lastBackup().EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+
+	targetSize := targetRestoreSpanSize.Get(&execCfg.Settings.SV)
path: pkg/backup/compaction_processor.go
user: login: msbutler
body: we rely on these two fields being identical between distsql planning and execution, so we should read them once, during proc planning and then pass them as specs to the proc
created_at: 2025-04-01T16:26:35Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
+		}
+	}
+
+	return &execinfrapb.ProducerMetadata{BulkProcessorProgress: &progCopy}
+}
+
+func (p *compactBackupsProcessor) close() {
+	if p.Closed {
+		return
+	}
+	if p.cancelAndWaitForWorker != nil {
+		p.cancelAndWaitForWorker()
+	}
+	p.InternalClose()
+}
+
+// ConsumerClosed is part of the RowSource interface. We have to override the
+// implementation provided by ProcessorBase.
+func (p *compactBackupsProcessor) ConsumerClosed() {
+	p.close()
+}
+
+func runCompactBackups(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	spec execinfrapb.CompactBackupsSpec,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	if len(spec.AssignedSpans) == 0 {
+		return nil
+	}
+	user := spec.User()
+	execCfg, ok := flowCtx.Cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	if !ok {
+		return errors.New("executor config is not of type sql.ExecutorConfig")
+	}
+	defaultConf, err := cloud.ExternalStorageConfFromURI(spec.DefaultURI, user)
+	if err != nil {
+		return errors.Wrapf(err, "export configuration")
+	}
+	defaultStore, err := execCfg.DistSQLSrv.ExternalStorage(ctx, defaultConf)
+	if err != nil {
+		return errors.Wrapf(err, "external storage")
+	}
+
+	compactChain, encryption, err := compactionChainFromSpec(ctx, execCfg, spec, user)
+	if err != nil {
+		return err
+	}
+
+	intersectSpanCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	tasks := []func(context.Context) error{
+		func(ctx context.Context) error {
+			defer close(intersectSpanCh)
+			return genIntersectingSpansForCompaction(ctx, execCfg, spec, compactChain, intersectSpanCh)
+		},
+		func(ctx context.Context) error {
+			return processIntersectingSpanEntries(
+				ctx, execCfg, spec, intersectSpanCh, encryption, progCh, defaultStore,
+			)
+		},
+	}
+
+	return ctxgroup.GoAndWait(ctx, tasks...)
+}
+
+func processIntersectingSpanEntries(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	entryCh chan execinfrapb.RestoreSpanEntry,
+	encryption *jobspb.BackupEncryptionOptions,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+	store cloud.ExternalStorage,
+) error {
+	var fileEncryption *kvpb.FileEncryptionOptions
+	if encryption != nil {
+		fileEncryption = &kvpb.FileEncryptionOptions{Key: encryption.Key}
+	}
+	sinkConf := backupsink.SSTSinkConf{
+		ID:        execCfg.DistSQLSrv.NodeID.SQLInstanceID(),
+		Enc:       fileEncryption,
+		ProgCh:    progCh,
+		Settings:  &execCfg.Settings.SV,
+		ElideMode: spec.ElideMode,
+	}
+	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := sink.Flush(ctx); err != nil {
+			log.Warningf(ctx, "failed to flush sink: %v", err)
+			logClose(ctx, sink, "SST sink")
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case entry, ok := <-entryCh:
+			if !ok {
+				return nil
+			}
+
+			sstIter, err := openSSTs(ctx, execCfg, entry, fileEncryption, spec.EndTime)
+			if err != nil {
+				return errors.Wrap(err, "opening SSTs")
+			}
+
+			if err := compactSpanEntry(ctx, sstIter, sink); err != nil {
+				return errors.Wrap(err, "compacting span entry")
+			}
+		}
+	}
+}
+
+func compactionChainFromSpec(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	user username.SQLUsername,
+) (compactionChain, *jobspb.BackupEncryptionOptions, error) {
+	kmsEnv := backupencryption.MakeBackupKMSEnv(
+		execCfg.Settings,
+		&execCfg.ExternalIODirConfig,
+		execCfg.InternalDB,
+		user,
+	)
+	prevManifests, localityInfo, encryption, allIters, err := getBackupChain(
+		ctx, execCfg, user, spec.Destination, spec.Encryption, spec.EndTime, &kmsEnv,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	compactChain, err := newCompactionChain(
+		prevManifests, spec.StartTime, spec.EndTime, localityInfo, allIters,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	return compactChain, encryption, nil
+}
+
+func genIntersectingSpansForCompaction(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	compactChain compactionChain,
+	intersectSpanCh chan execinfrapb.RestoreSpanEntry,
+) error {
+	backupLocalityMap, err := makeBackupLocalityMap(compactChain.compactedLocalityInfo, spec.User())
+	if err != nil {
+		return err
+	}
+
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, compactChain.lastBackup().EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+
+	targetSize := targetRestoreSpanSize.Get(&execCfg.Settings.SV)
+	maxFiles := maxFileCount.Get(&execCfg.Settings.SV)
+
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		spec.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	genSpan := func(ctx context.Context, entryCh chan execinfrapb.RestoreSpanEntry) error {
+		defer close(entryCh)
+		return errors.Wrapf(generateAndSendImportSpans(
+			ctx,
+			spec.Spans,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			entryCh,
+		), "generate and send import spans")
+	}
+
+	filterIntersectingSpans := func(ctx context.Context, entryCh chan execinfrapb.RestoreSpanEntry) error {
+		for {
+			select {
+			case <-ctx.Done():
+				return ctx.Err()
+			case entry, ok := <-entryCh:
+				if !ok {
+					return nil
+				}
+				for _, assignedSpan := range spec.AssignedSpans {
+					if assignedSpan.Overlaps(entry.Span) {
path: pkg/backup/compaction_processor.go
user: login: msbutler
body: i think we should use assignSpan.Contains() instead. 

Perhaps as a runtime assertion, we should also error if an entry overlaps with an assigned span but is _not_ contained by it. That would imply that 2 procs are going to work on the same restore span entry, which would lead to sadness.
created_at: 2025-04-01T16:34:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
+		}
+	}
+
+	return &execinfrapb.ProducerMetadata{BulkProcessorProgress: &progCopy}
+}
+
+func (p *compactBackupsProcessor) close() {
+	if p.Closed {
+		return
+	}
+	if p.cancelAndWaitForWorker != nil {
+		p.cancelAndWaitForWorker()
+	}
+	p.InternalClose()
+}
+
+// ConsumerClosed is part of the RowSource interface. We have to override the
+// implementation provided by ProcessorBase.
+func (p *compactBackupsProcessor) ConsumerClosed() {
+	p.close()
+}
+
+func runCompactBackups(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	spec execinfrapb.CompactBackupsSpec,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	if len(spec.AssignedSpans) == 0 {
+		return nil
+	}
+	user := spec.User()
+	execCfg, ok := flowCtx.Cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	if !ok {
+		return errors.New("executor config is not of type sql.ExecutorConfig")
+	}
+	defaultConf, err := cloud.ExternalStorageConfFromURI(spec.DefaultURI, user)
+	if err != nil {
+		return errors.Wrapf(err, "export configuration")
+	}
+	defaultStore, err := execCfg.DistSQLSrv.ExternalStorage(ctx, defaultConf)
+	if err != nil {
+		return errors.Wrapf(err, "external storage")
+	}
+
+	compactChain, encryption, err := compactionChainFromSpec(ctx, execCfg, spec, user)
+	if err != nil {
+		return err
+	}
+
+	intersectSpanCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	tasks := []func(context.Context) error{
+		func(ctx context.Context) error {
+			defer close(intersectSpanCh)
+			return genIntersectingSpansForCompaction(ctx, execCfg, spec, compactChain, intersectSpanCh)
+		},
+		func(ctx context.Context) error {
+			return processIntersectingSpanEntries(
+				ctx, execCfg, spec, intersectSpanCh, encryption, progCh, defaultStore,
+			)
+		},
+	}
+
+	return ctxgroup.GoAndWait(ctx, tasks...)
+}
+
+func processIntersectingSpanEntries(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	entryCh chan execinfrapb.RestoreSpanEntry,
+	encryption *jobspb.BackupEncryptionOptions,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+	store cloud.ExternalStorage,
+) error {
+	var fileEncryption *kvpb.FileEncryptionOptions
+	if encryption != nil {
+		fileEncryption = &kvpb.FileEncryptionOptions{Key: encryption.Key}
+	}
+	sinkConf := backupsink.SSTSinkConf{
+		ID:        execCfg.DistSQLSrv.NodeID.SQLInstanceID(),
+		Enc:       fileEncryption,
+		ProgCh:    progCh,
+		Settings:  &execCfg.Settings.SV,
+		ElideMode: spec.ElideMode,
+	}
+	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := sink.Flush(ctx); err != nil {
+			log.Warningf(ctx, "failed to flush sink: %v", err)
+			logClose(ctx, sink, "SST sink")
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case entry, ok := <-entryCh:
+			if !ok {
+				return nil
+			}
+
+			sstIter, err := openSSTs(ctx, execCfg, entry, fileEncryption, spec.EndTime)
+			if err != nil {
+				return errors.Wrap(err, "opening SSTs")
+			}
+
+			if err := compactSpanEntry(ctx, sstIter, sink); err != nil {
+				return errors.Wrap(err, "compacting span entry")
+			}
+		}
+	}
+}
+
+func compactionChainFromSpec(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	user username.SQLUsername,
+) (compactionChain, *jobspb.BackupEncryptionOptions, error) {
+	kmsEnv := backupencryption.MakeBackupKMSEnv(
+		execCfg.Settings,
+		&execCfg.ExternalIODirConfig,
+		execCfg.InternalDB,
+		user,
+	)
+	prevManifests, localityInfo, encryption, allIters, err := getBackupChain(
+		ctx, execCfg, user, spec.Destination, spec.Encryption, spec.EndTime, &kmsEnv,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	compactChain, err := newCompactionChain(
+		prevManifests, spec.StartTime, spec.EndTime, localityInfo, allIters,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	return compactChain, encryption, nil
+}
+
+func genIntersectingSpansForCompaction(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	compactChain compactionChain,
+	intersectSpanCh chan execinfrapb.RestoreSpanEntry,
+) error {
+	backupLocalityMap, err := makeBackupLocalityMap(compactChain.compactedLocalityInfo, spec.User())
+	if err != nil {
+		return err
+	}
+
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, compactChain.lastBackup().EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+
+	targetSize := targetRestoreSpanSize.Get(&execCfg.Settings.SV)
+	maxFiles := maxFileCount.Get(&execCfg.Settings.SV)
+
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		spec.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	genSpan := func(ctx context.Context, entryCh chan execinfrapb.RestoreSpanEntry) error {
+		defer close(entryCh)
+		return errors.Wrapf(generateAndSendImportSpans(
+			ctx,
+			spec.Spans,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			entryCh,
+		), "generate and send import spans")
+	}
+
+	filterIntersectingSpans := func(ctx context.Context, entryCh chan execinfrapb.RestoreSpanEntry) error {
path: pkg/backup/compaction_processor.go
user: login: msbutler
body: instead of this multilevel ctxgroup setup, could we simplify by hooking up genSpan's raw entryCh to `processIntersectingSpanEntries` which would do the filter logic right above the openSSTs call?
created_at: 2025-04-01T16:38:17Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
+		}
+	}
+
+	return &execinfrapb.ProducerMetadata{BulkProcessorProgress: &progCopy}
+}
+
+func (p *compactBackupsProcessor) close() {
+	if p.Closed {
+		return
+	}
+	if p.cancelAndWaitForWorker != nil {
+		p.cancelAndWaitForWorker()
+	}
+	p.InternalClose()
+}
+
+// ConsumerClosed is part of the RowSource interface. We have to override the
+// implementation provided by ProcessorBase.
+func (p *compactBackupsProcessor) ConsumerClosed() {
+	p.close()
+}
+
+func runCompactBackups(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	spec execinfrapb.CompactBackupsSpec,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	if len(spec.AssignedSpans) == 0 {
+		return nil
+	}
+	user := spec.User()
+	execCfg, ok := flowCtx.Cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	if !ok {
+		return errors.New("executor config is not of type sql.ExecutorConfig")
+	}
+	defaultConf, err := cloud.ExternalStorageConfFromURI(spec.DefaultURI, user)
+	if err != nil {
+		return errors.Wrapf(err, "export configuration")
+	}
+	defaultStore, err := execCfg.DistSQLSrv.ExternalStorage(ctx, defaultConf)
+	if err != nil {
+		return errors.Wrapf(err, "external storage")
+	}
+
+	compactChain, encryption, err := compactionChainFromSpec(ctx, execCfg, spec, user)
+	if err != nil {
+		return err
+	}
+
+	intersectSpanCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	tasks := []func(context.Context) error{
+		func(ctx context.Context) error {
+			defer close(intersectSpanCh)
+			return genIntersectingSpansForCompaction(ctx, execCfg, spec, compactChain, intersectSpanCh)
+		},
+		func(ctx context.Context) error {
+			return processIntersectingSpanEntries(
+				ctx, execCfg, spec, intersectSpanCh, encryption, progCh, defaultStore,
+			)
+		},
+	}
+
+	return ctxgroup.GoAndWait(ctx, tasks...)
+}
+
+func processIntersectingSpanEntries(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	entryCh chan execinfrapb.RestoreSpanEntry,
+	encryption *jobspb.BackupEncryptionOptions,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+	store cloud.ExternalStorage,
+) error {
+	var fileEncryption *kvpb.FileEncryptionOptions
+	if encryption != nil {
+		fileEncryption = &kvpb.FileEncryptionOptions{Key: encryption.Key}
+	}
+	sinkConf := backupsink.SSTSinkConf{
+		ID:        execCfg.DistSQLSrv.NodeID.SQLInstanceID(),
+		Enc:       fileEncryption,
+		ProgCh:    progCh,
+		Settings:  &execCfg.Settings.SV,
+		ElideMode: spec.ElideMode,
+	}
+	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := sink.Flush(ctx); err != nil {
+			log.Warningf(ctx, "failed to flush sink: %v", err)
+			logClose(ctx, sink, "SST sink")
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case entry, ok := <-entryCh:
+			if !ok {
+				return nil
+			}
+
+			sstIter, err := openSSTs(ctx, execCfg, entry, fileEncryption, spec.EndTime)
+			if err != nil {
+				return errors.Wrap(err, "opening SSTs")
+			}
+
+			if err := compactSpanEntry(ctx, sstIter, sink); err != nil {
+				return errors.Wrap(err, "compacting span entry")
+			}
+		}
+	}
+}
+
+func compactionChainFromSpec(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	user username.SQLUsername,
+) (compactionChain, *jobspb.BackupEncryptionOptions, error) {
+	kmsEnv := backupencryption.MakeBackupKMSEnv(
+		execCfg.Settings,
+		&execCfg.ExternalIODirConfig,
+		execCfg.InternalDB,
+		user,
+	)
+	prevManifests, localityInfo, encryption, allIters, err := getBackupChain(
+		ctx, execCfg, user, spec.Destination, spec.Encryption, spec.EndTime, &kmsEnv,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	compactChain, err := newCompactionChain(
+		prevManifests, spec.StartTime, spec.EndTime, localityInfo, allIters,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	return compactChain, encryption, nil
+}
+
+func genIntersectingSpansForCompaction(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	compactChain compactionChain,
+	intersectSpanCh chan execinfrapb.RestoreSpanEntry,
+) error {
+	backupLocalityMap, err := makeBackupLocalityMap(compactChain.compactedLocalityInfo, spec.User())
+	if err != nil {
+		return err
+	}
+
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, compactChain.lastBackup().EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+
+	targetSize := targetRestoreSpanSize.Get(&execCfg.Settings.SV)
+	maxFiles := maxFileCount.Get(&execCfg.Settings.SV)
+
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		spec.Spans,
path: pkg/backup/compaction_processor.go
user: login: msbutler
body: we should use the same spans for planning and here. spec.Spans only contains the spans assigned to the proc.
created_at: 2025-04-01T16:43:15Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
path: pkg/backup/compaction_processor.go
user: login: msbutler
body: heh, i think you can get rid of this. no need to add a `storePerNodeProgressLoop` to compaction for now.
created_at: 2025-04-01T16:44:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
path: pkg/backup/compaction_processor.go
user: login: msbutler
body: i think this is part of some o11y that we never deployed.
created_at: 2025-04-01T16:45:14Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
path: pkg/backup/compaction_processor.go
user: login: msbutler
body: yeah, i actually agree with you. @dt is this a historical artifact?
created_at: 2025-04-01T16:49:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
+		}
+	}
+
+	return &execinfrapb.ProducerMetadata{BulkProcessorProgress: &progCopy}
+}
+
+func (p *compactBackupsProcessor) close() {
+	if p.Closed {
+		return
+	}
+	if p.cancelAndWaitForWorker != nil {
+		p.cancelAndWaitForWorker()
+	}
+	p.InternalClose()
+}
+
+// ConsumerClosed is part of the RowSource interface. We have to override the
+// implementation provided by ProcessorBase.
+func (p *compactBackupsProcessor) ConsumerClosed() {
+	p.close()
+}
+
+func runCompactBackups(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	spec execinfrapb.CompactBackupsSpec,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	if len(spec.AssignedSpans) == 0 {
+		return nil
+	}
+	user := spec.User()
+	execCfg, ok := flowCtx.Cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	if !ok {
+		return errors.New("executor config is not of type sql.ExecutorConfig")
+	}
+	defaultConf, err := cloud.ExternalStorageConfFromURI(spec.DefaultURI, user)
+	if err != nil {
+		return errors.Wrapf(err, "export configuration")
+	}
+	defaultStore, err := execCfg.DistSQLSrv.ExternalStorage(ctx, defaultConf)
+	if err != nil {
+		return errors.Wrapf(err, "external storage")
+	}
+
+	compactChain, encryption, err := compactionChainFromSpec(ctx, execCfg, spec, user)
+	if err != nil {
+		return err
+	}
+
+	intersectSpanCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	tasks := []func(context.Context) error{
+		func(ctx context.Context) error {
+			defer close(intersectSpanCh)
+			return genIntersectingSpansForCompaction(ctx, execCfg, spec, compactChain, intersectSpanCh)
+		},
+		func(ctx context.Context) error {
+			return processIntersectingSpanEntries(
+				ctx, execCfg, spec, intersectSpanCh, encryption, progCh, defaultStore,
+			)
+		},
+	}
+
+	return ctxgroup.GoAndWait(ctx, tasks...)
+}
+
+func processIntersectingSpanEntries(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	entryCh chan execinfrapb.RestoreSpanEntry,
+	encryption *jobspb.BackupEncryptionOptions,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+	store cloud.ExternalStorage,
+) error {
+	var fileEncryption *kvpb.FileEncryptionOptions
+	if encryption != nil {
+		fileEncryption = &kvpb.FileEncryptionOptions{Key: encryption.Key}
+	}
+	sinkConf := backupsink.SSTSinkConf{
+		ID:        execCfg.DistSQLSrv.NodeID.SQLInstanceID(),
+		Enc:       fileEncryption,
+		ProgCh:    progCh,
+		Settings:  &execCfg.Settings.SV,
+		ElideMode: spec.ElideMode,
+	}
+	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := sink.Flush(ctx); err != nil {
+			log.Warningf(ctx, "failed to flush sink: %v", err)
+			logClose(ctx, sink, "SST sink")
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case entry, ok := <-entryCh:
+			if !ok {
+				return nil
+			}
+
+			sstIter, err := openSSTs(ctx, execCfg, entry, fileEncryption, spec.EndTime)
+			if err != nil {
+				return errors.Wrap(err, "opening SSTs")
+			}
+
+			if err := compactSpanEntry(ctx, sstIter, sink); err != nil {
+				return errors.Wrap(err, "compacting span entry")
+			}
+		}
+	}
+}
+
+func compactionChainFromSpec(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	user username.SQLUsername,
+) (compactionChain, *jobspb.BackupEncryptionOptions, error) {
+	kmsEnv := backupencryption.MakeBackupKMSEnv(
+		execCfg.Settings,
+		&execCfg.ExternalIODirConfig,
+		execCfg.InternalDB,
+		user,
+	)
+	prevManifests, localityInfo, encryption, allIters, err := getBackupChain(
+		ctx, execCfg, user, spec.Destination, spec.Encryption, spec.EndTime, &kmsEnv,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	compactChain, err := newCompactionChain(
+		prevManifests, spec.StartTime, spec.EndTime, localityInfo, allIters,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	return compactChain, encryption, nil
+}
+
+func genIntersectingSpansForCompaction(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	compactChain compactionChain,
+	intersectSpanCh chan execinfrapb.RestoreSpanEntry,
+) error {
+	backupLocalityMap, err := makeBackupLocalityMap(compactChain.compactedLocalityInfo, spec.User())
+	if err != nil {
+		return err
+	}
+
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, compactChain.lastBackup().EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+
+	targetSize := targetRestoreSpanSize.Get(&execCfg.Settings.SV)
+	maxFiles := maxFileCount.Get(&execCfg.Settings.SV)
+
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		spec.Spans,
path: pkg/backup/compaction_processor.go
user: login: kev-cao
body: Oh actually `spec.Spans` is the set of all spans that need to be compacted, excluding any that have already been completed (it's what is passed to `generateAndSendImportSpans` in both planning and the processor). `AssignedSpans` is what is assigned to the proc.
created_at: 2025-04-01T17:14:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -938,18 +679,28 @@ func getBackupChain(
 			log.Warningf(ctx, "failed to cleanup incremental backup stores: %+v", err)
 		}
 	}()
-	encryption, err := backupencryption.GetEncryptionFromBaseStore(
-		ctx, baseStores[0], details.EncryptionOptions, kmsEnv,
-	)
-	if err != nil {
-		return nil, nil, nil, nil, err
+	// If encryption keys have not already been computed, then we will compute
path: pkg/backup/compaction_job.go
user: login: kev-cao
body: Yeah things break since the processors won't be able to decrypt the files.
created_at: 2025-04-01T17:16:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
path: pkg/backup/compaction_processor.go
user: login: dt
body: historical artifact indeed: this code was originally more or less:
```
for prog := range progressCh {
  p := prog
  emit(&p)
}
```

In that construction, we were emitting the _address of_ `p` , rather than its value, and until go1.22, there is only one allocated variable `prog` that has a new value stored in it for each iteration of the loop, so if we'd emitted the address of `prog`, we'd have been emitting the same address over and over, and the receiver of two emitted messages might then read the latest value for both of them.

The copy allocated a new variable in each iteration, the address of which would be unique and safe to emit. 

In go 1.22, go changed `for` itself to make a new variable for every iteration (making it slower, but less prone to mistakes) making our explicit copy pointless.  Even before 1.22 though our refactors meant we started passing `prog` -- by value -- to a function to do some wrap/unwrap before emitting it, and passing it to a function by value is also making a new copy anyway. 

So this explicit copy is doubly pointless now.
created_at: 2025-04-01T17:26:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +771,29 @@ func (i *IngestStoppedSpec) summary() (string, []string) {
 	return "IngestStoppedSpec", []string{detail}
 }
 
+// summary implements the diagramCellType interface.
path: pkg/sql/execinfrapb/flow_diagram.go
user: login: kev-cao
body: If I ever need a boost of serotonin, I just go to the distSQL view and move those boxes around 
created_at: 2025-04-01T17:53:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,329 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupinfo"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobsprofiler"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/physicalplan"
+	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/util"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/errors"
+)
+
+// runCompactionPlan creates and runs a distsql plan to compact the spans
+// in the backup chain that need to be compacted. It sends updates from the
+// BulkProcessor to the provided progress channel. It is the caller's
+// responsibility to close the progress channel.
+//
+//lint:ignore U1000 unused
+func runCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	compactChain compactionChain,
+	manifest *backuppb.BackupManifest,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+	progCh chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	log.Infof(
+		ctx, "planning compaction of %d backups: %s",
+		len(compactChain.chainToCompact),
+		util.Map(compactChain.chainToCompact, func(m backuppb.BackupManifest) string {
+			return m.ID.String()
+		}),
+	)
+	backupLocalityMap, err := makeBackupLocalityMap(
+		compactChain.compactedLocalityInfo, execCtx.User(),
+	)
+	if err != nil {
+		return err
+	}
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, manifest.EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+	targetSize := targetRestoreSpanSize.Get(&execCtx.ExecCfg().Settings.SV)
+	maxFiles := maxFileCount.Get(&execCtx.ExecCfg().Settings.SV)
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		manifest.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	spansToCompact, err := getSpansToCompact(
+		ctx, execCtx, manifest, compactChain.chainToCompact, details, defaultStore, kmsEnv,
+	)
+	if err != nil {
+		return err
+	}
+	genSpan := func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error {
+		return errors.Wrap(generateAndSendImportSpans(
+			ctx,
+			spansToCompact,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			spanCh,
+		), "generateAndSendImportSpans")
+	}
+	dsp := execCtx.DistSQLPlanner()
+	plan, planCtx, err := createCompactionPlan(
+		ctx, execCtx, jobID, details, manifest, dsp, genSpan, spansToCompact,
+	)
+	if err != nil {
+		return errors.Wrap(err, "creating compaction plan")
+	}
+	sql.FinalizePlan(ctx, planCtx, plan)
+
+	metaFn := func(_ context.Context, meta *execinfrapb.ProducerMetadata) error {
+		if meta.BulkProcessorProgress != nil {
+			progCh <- meta.BulkProcessorProgress
+		}
+
+		// TODO (kev-cao): Add channel for tracing aggregator events.
+		return nil
+	}
+	rowResultWriter := sql.NewRowResultWriter(nil)
+	recv := sql.MakeDistSQLReceiver(
+		ctx,
+		sql.NewMetadataCallbackWriter(rowResultWriter, metaFn),
+		tree.Rows,
+		nil, /* rangeCache */
+		nil, /* txn */
+		nil, /* clockUpdater */
+		execCtx.ExtendedEvalContext().Tracing,
+	)
+	defer recv.Release()
+
+	jobsprofiler.StorePlanDiagram(
+		ctx, execCtx.ExecCfg().DistSQLSrv.Stopper, plan, execCtx.ExecCfg().InternalDB, jobID,
+	)
+
+	evalCtxCopy := execCtx.ExtendedEvalContext().Copy()
+	dsp.Run(ctx, planCtx, nil /* txn */, plan, recv, evalCtxCopy, nil /* finishedSetupFn */)
+	return nil
+}
+
+// createCompactionPlan creates an un-finalized physical plan that will
+// distribute spans from a generator across the cluster for compaction.
+func createCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	manifest *backuppb.BackupManifest,
+	dsp *sql.DistSQLPlanner,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+) (*sql.PhysicalPlan, *sql.PlanningCtx, error) {
+	numEntries, err := countRestoreSpanEntries(ctx, genSpan)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "counting number of restore span entries")
+	}
+
+	planCtx, sqlInstanceIDs, err := dsp.SetupAllNodesPlanningWithOracle(
+		ctx, execCtx.ExtendedEvalContext(), execCtx.ExecCfg(),
+		physicalplan.DefaultReplicaChooser, roachpb.Locality{},
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	plan := planCtx.NewPhysicalPlan()
+	corePlacements, err := createCompactionCorePlacements(
+		ctx, jobID, execCtx.User(), details, manifest.ElidedPrefix,
+		genSpan, spansToCompact, sqlInstanceIDs, numEntries,
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+	plan.AddNoInputStage(
+		corePlacements,
+		execinfrapb.PostProcessSpec{},
+		[]*types.T{},
+		execinfrapb.Ordering{},
+		nil, /* finalizeLastStageCb */
+	)
+	return plan, planCtx, nil
+}
+
+// countRestoreSpanEntries counts the number of restore span entries that will be
+// be delivered by the given generator.
+func countRestoreSpanEntries(
+	ctx context.Context,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+) (int, error) {
+	countSpansCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var numImportSpans int
+	countTasks := []func(ctx context.Context) error{
+		func(ctx context.Context) error {
+			for range countSpansCh {
+				numImportSpans++
+			}
+			return nil
+		},
+		func(ctx context.Context) error {
+			defer close(countSpansCh)
+			return genSpan(ctx, countSpansCh)
+		},
+	}
+	if err := ctxgroup.GoAndWait(ctx, countTasks...); err != nil {
+		return 0, errors.Wrapf(err, "counting number of spans to compact")
+	}
+	return numImportSpans, nil
+}
+
+// createCompactionCorePlacements takes spans from a generator and evenly
+// distributes them across nodes in the cluster, returning the core placements
+// reflecting that distribution.
+//
+// TODO (kev-cao): This currently distributes the spans so that each node gets
+// roughly the same number of entries. This could be further improved to take
+// into consideration the size of the spans.
+func createCompactionCorePlacements(
+	ctx context.Context,
+	jobID jobspb.JobID,
+	user username.SQLUsername,
+	details jobspb.BackupDetails,
+	elideMode execinfrapb.ElidePrefix,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+	sqlInstanceIDs []base.SQLInstanceID,
+	numEntries int,
+) ([]physicalplan.ProcessorCorePlacement, error) {
+	numNodes := len(sqlInstanceIDs)
+	corePlacements := make([]physicalplan.ProcessorCorePlacement, numNodes)
+	for i := range corePlacements {
+		corePlacements[i].SQLInstanceID = sqlInstanceIDs[i]
+		corePlacements[i].Core.CompactBackups = &execinfrapb.CompactBackupsSpec{
+			JobID:       int64(jobID),
+			DefaultURI:  details.URI,
+			Destination: details.Destination,
+			Encryption:  details.EncryptionOptions,
+			StartTime:   details.StartTime,
+			EndTime:     details.EndTime,
+			ElideMode:   elideMode,
+			UserProto:   user.EncodeProto(),
+			Spans:       spansToCompact,
+		}
+	}
+
+	spanEntryCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var tasks []func(ctx context.Context) error
+	tasks = append(tasks, func(ctx context.Context) error {
+		defer close(spanEntryCh)
+		return genSpan(ctx, spanEntryCh)
+	})
+	tasks = append(tasks, func(ctx context.Context) error {
+		numEntriesPerNode := numEntries / numNodes
+		leftoverEntries := numEntries % numNodes
+		getTargetNumEntries := func(nodeIdx int) int {
+			if nodeIdx < leftoverEntries {
+				// This more evenly distributes the leftover entries across the nodes
+				// after doing integer division to assign the entries to the nodes.
+				return numEntriesPerNode + 1
+			}
+			return numEntriesPerNode
+		}
+		currNode := 0
+		currEntries := make([]execinfrapb.RestoreSpanEntry, 0, numEntriesPerNode+1)
+		targetNumEntries := getTargetNumEntries(currNode)
+
+		for entry := range spanEntryCh {
+			currEntries = append(currEntries, entry)
path: pkg/backup/compaction_processor_planning.go
user: login: kev-cao
body: That makes a lot of sense, updated!
created_at: 2025-04-01T18:31:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
+		}
+	}
+
+	return &execinfrapb.ProducerMetadata{BulkProcessorProgress: &progCopy}
+}
+
+func (p *compactBackupsProcessor) close() {
+	if p.Closed {
+		return
+	}
+	if p.cancelAndWaitForWorker != nil {
+		p.cancelAndWaitForWorker()
+	}
+	p.InternalClose()
+}
+
+// ConsumerClosed is part of the RowSource interface. We have to override the
+// implementation provided by ProcessorBase.
+func (p *compactBackupsProcessor) ConsumerClosed() {
+	p.close()
+}
+
+func runCompactBackups(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	spec execinfrapb.CompactBackupsSpec,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	if len(spec.AssignedSpans) == 0 {
+		return nil
+	}
+	user := spec.User()
+	execCfg, ok := flowCtx.Cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	if !ok {
+		return errors.New("executor config is not of type sql.ExecutorConfig")
+	}
+	defaultConf, err := cloud.ExternalStorageConfFromURI(spec.DefaultURI, user)
+	if err != nil {
+		return errors.Wrapf(err, "export configuration")
+	}
+	defaultStore, err := execCfg.DistSQLSrv.ExternalStorage(ctx, defaultConf)
+	if err != nil {
+		return errors.Wrapf(err, "external storage")
+	}
+
+	compactChain, encryption, err := compactionChainFromSpec(ctx, execCfg, spec, user)
+	if err != nil {
+		return err
+	}
+
+	intersectSpanCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	tasks := []func(context.Context) error{
+		func(ctx context.Context) error {
+			defer close(intersectSpanCh)
+			return genIntersectingSpansForCompaction(ctx, execCfg, spec, compactChain, intersectSpanCh)
+		},
+		func(ctx context.Context) error {
+			return processIntersectingSpanEntries(
+				ctx, execCfg, spec, intersectSpanCh, encryption, progCh, defaultStore,
+			)
+		},
+	}
+
+	return ctxgroup.GoAndWait(ctx, tasks...)
+}
+
+func processIntersectingSpanEntries(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	entryCh chan execinfrapb.RestoreSpanEntry,
+	encryption *jobspb.BackupEncryptionOptions,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+	store cloud.ExternalStorage,
+) error {
+	var fileEncryption *kvpb.FileEncryptionOptions
+	if encryption != nil {
+		fileEncryption = &kvpb.FileEncryptionOptions{Key: encryption.Key}
+	}
+	sinkConf := backupsink.SSTSinkConf{
+		ID:        execCfg.DistSQLSrv.NodeID.SQLInstanceID(),
+		Enc:       fileEncryption,
+		ProgCh:    progCh,
+		Settings:  &execCfg.Settings.SV,
+		ElideMode: spec.ElideMode,
+	}
+	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := sink.Flush(ctx); err != nil {
+			log.Warningf(ctx, "failed to flush sink: %v", err)
+			logClose(ctx, sink, "SST sink")
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case entry, ok := <-entryCh:
+			if !ok {
+				return nil
+			}
+
+			sstIter, err := openSSTs(ctx, execCfg, entry, fileEncryption, spec.EndTime)
+			if err != nil {
+				return errors.Wrap(err, "opening SSTs")
+			}
+
+			if err := compactSpanEntry(ctx, sstIter, sink); err != nil {
+				return errors.Wrap(err, "compacting span entry")
+			}
+		}
+	}
+}
+
+func compactionChainFromSpec(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	user username.SQLUsername,
+) (compactionChain, *jobspb.BackupEncryptionOptions, error) {
+	kmsEnv := backupencryption.MakeBackupKMSEnv(
+		execCfg.Settings,
+		&execCfg.ExternalIODirConfig,
+		execCfg.InternalDB,
+		user,
+	)
+	prevManifests, localityInfo, encryption, allIters, err := getBackupChain(
+		ctx, execCfg, user, spec.Destination, spec.Encryption, spec.EndTime, &kmsEnv,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	compactChain, err := newCompactionChain(
+		prevManifests, spec.StartTime, spec.EndTime, localityInfo, allIters,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	return compactChain, encryption, nil
+}
+
+func genIntersectingSpansForCompaction(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	compactChain compactionChain,
+	intersectSpanCh chan execinfrapb.RestoreSpanEntry,
+) error {
+	backupLocalityMap, err := makeBackupLocalityMap(compactChain.compactedLocalityInfo, spec.User())
+	if err != nil {
+		return err
+	}
+
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, compactChain.lastBackup().EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+
+	targetSize := targetRestoreSpanSize.Get(&execCfg.Settings.SV)
+	maxFiles := maxFileCount.Get(&execCfg.Settings.SV)
+
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		spec.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	genSpan := func(ctx context.Context, entryCh chan execinfrapb.RestoreSpanEntry) error {
+		defer close(entryCh)
+		return errors.Wrapf(generateAndSendImportSpans(
+			ctx,
+			spec.Spans,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			entryCh,
+		), "generate and send import spans")
+	}
+
+	filterIntersectingSpans := func(ctx context.Context, entryCh chan execinfrapb.RestoreSpanEntry) error {
path: pkg/backup/compaction_processor.go
user: login: kev-cao
body: Oh yeah that would be nicer, will do!
created_at: 2025-04-01T19:07:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,465 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = runCompactBackups(ctx, p.FlowCtx, p.spec, p.progCh)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	// Take a copy so that we can send the progress address to the output
+	// processor.
+	progCopy := prog
+	progCopy.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	progCopy.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		totalSpans := int32(len(p.spec.Spans))
+		p.completedSpans += progDetails.CompletedSpans
+		if totalSpans != 0 {
+			if progCopy.CompletedFraction == nil {
+				progCopy.CompletedFraction = make(map[int32]float32)
+			}
+			progCopy.CompletedFraction[p.ProcessorID] = float32(p.completedSpans) / float32(totalSpans)
+		}
+	}
+
+	return &execinfrapb.ProducerMetadata{BulkProcessorProgress: &progCopy}
+}
+
+func (p *compactBackupsProcessor) close() {
+	if p.Closed {
+		return
+	}
+	if p.cancelAndWaitForWorker != nil {
+		p.cancelAndWaitForWorker()
+	}
+	p.InternalClose()
+}
+
+// ConsumerClosed is part of the RowSource interface. We have to override the
+// implementation provided by ProcessorBase.
+func (p *compactBackupsProcessor) ConsumerClosed() {
+	p.close()
+}
+
+func runCompactBackups(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	spec execinfrapb.CompactBackupsSpec,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	if len(spec.AssignedSpans) == 0 {
+		return nil
+	}
+	user := spec.User()
+	execCfg, ok := flowCtx.Cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	if !ok {
+		return errors.New("executor config is not of type sql.ExecutorConfig")
+	}
+	defaultConf, err := cloud.ExternalStorageConfFromURI(spec.DefaultURI, user)
+	if err != nil {
+		return errors.Wrapf(err, "export configuration")
+	}
+	defaultStore, err := execCfg.DistSQLSrv.ExternalStorage(ctx, defaultConf)
+	if err != nil {
+		return errors.Wrapf(err, "external storage")
+	}
+
+	compactChain, encryption, err := compactionChainFromSpec(ctx, execCfg, spec, user)
+	if err != nil {
+		return err
+	}
+
+	intersectSpanCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	tasks := []func(context.Context) error{
+		func(ctx context.Context) error {
+			defer close(intersectSpanCh)
+			return genIntersectingSpansForCompaction(ctx, execCfg, spec, compactChain, intersectSpanCh)
+		},
+		func(ctx context.Context) error {
+			return processIntersectingSpanEntries(
+				ctx, execCfg, spec, intersectSpanCh, encryption, progCh, defaultStore,
+			)
+		},
+	}
+
+	return ctxgroup.GoAndWait(ctx, tasks...)
+}
+
+func processIntersectingSpanEntries(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	entryCh chan execinfrapb.RestoreSpanEntry,
+	encryption *jobspb.BackupEncryptionOptions,
+	progCh chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+	store cloud.ExternalStorage,
+) error {
+	var fileEncryption *kvpb.FileEncryptionOptions
+	if encryption != nil {
+		fileEncryption = &kvpb.FileEncryptionOptions{Key: encryption.Key}
+	}
+	sinkConf := backupsink.SSTSinkConf{
+		ID:        execCfg.DistSQLSrv.NodeID.SQLInstanceID(),
+		Enc:       fileEncryption,
+		ProgCh:    progCh,
+		Settings:  &execCfg.Settings.SV,
+		ElideMode: spec.ElideMode,
+	}
+	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := sink.Flush(ctx); err != nil {
+			log.Warningf(ctx, "failed to flush sink: %v", err)
+			logClose(ctx, sink, "SST sink")
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case entry, ok := <-entryCh:
+			if !ok {
+				return nil
+			}
+
+			sstIter, err := openSSTs(ctx, execCfg, entry, fileEncryption, spec.EndTime)
+			if err != nil {
+				return errors.Wrap(err, "opening SSTs")
+			}
+
+			if err := compactSpanEntry(ctx, sstIter, sink); err != nil {
+				return errors.Wrap(err, "compacting span entry")
+			}
+		}
+	}
+}
+
+func compactionChainFromSpec(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	user username.SQLUsername,
+) (compactionChain, *jobspb.BackupEncryptionOptions, error) {
+	kmsEnv := backupencryption.MakeBackupKMSEnv(
+		execCfg.Settings,
+		&execCfg.ExternalIODirConfig,
+		execCfg.InternalDB,
+		user,
+	)
+	prevManifests, localityInfo, encryption, allIters, err := getBackupChain(
+		ctx, execCfg, user, spec.Destination, spec.Encryption, spec.EndTime, &kmsEnv,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	compactChain, err := newCompactionChain(
+		prevManifests, spec.StartTime, spec.EndTime, localityInfo, allIters,
+	)
+	if err != nil {
+		return compactionChain{}, nil, err
+	}
+	return compactChain, encryption, nil
+}
+
+func genIntersectingSpansForCompaction(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	spec execinfrapb.CompactBackupsSpec,
+	compactChain compactionChain,
+	intersectSpanCh chan execinfrapb.RestoreSpanEntry,
+) error {
+	backupLocalityMap, err := makeBackupLocalityMap(compactChain.compactedLocalityInfo, spec.User())
+	if err != nil {
+		return err
+	}
+
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, compactChain.lastBackup().EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+
+	targetSize := targetRestoreSpanSize.Get(&execCfg.Settings.SV)
+	maxFiles := maxFileCount.Get(&execCfg.Settings.SV)
+
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		spec.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	genSpan := func(ctx context.Context, entryCh chan execinfrapb.RestoreSpanEntry) error {
+		defer close(entryCh)
+		return errors.Wrapf(generateAndSendImportSpans(
+			ctx,
+			spec.Spans,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			entryCh,
+		), "generate and send import spans")
+	}
+
+	filterIntersectingSpans := func(ctx context.Context, entryCh chan execinfrapb.RestoreSpanEntry) error {
+		for {
+			select {
+			case <-ctx.Done():
+				return ctx.Err()
+			case entry, ok := <-entryCh:
+				if !ok {
+					return nil
+				}
+				for _, assignedSpan := range spec.AssignedSpans {
+					if assignedSpan.Overlaps(entry.Span) {
path: pkg/backup/compaction_processor.go
user: login: kev-cao
body: 👍 I do like avoiding sadness
created_at: 2025-04-01T19:30:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+	"testing"
+
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/util/leaktest"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCreateCompactionCorePlacements(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	ctx := context.Background()
+	testcases := []struct {
+		name                 string
+		entries              []execinfrapb.RestoreSpanEntry
+		numNodes             int
+		expectedDistribution []int // expected number of entries per node
+	}{
+		{
+			name: "single node, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: roachpb.Key("a"), EndKey: roachpb.Key("b")}},
+			},
+			numNodes:             1,
+			expectedDistribution: []int{1},
+		},
+		{
+			name: "single node, multiple span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: roachpb.Key("a"), EndKey: roachpb.Key("b")}},
+				{Span: roachpb.Span{Key: roachpb.Key("c"), EndKey: roachpb.Key("d")}},
+				{Span: roachpb.Span{Key: roachpb.Key("e"), EndKey: roachpb.Key("f")}},
+			},
path: pkg/backup/compaction_dist_test.go
user: login: kev-cao
body: TODO: Need to update these tests now that we are using span groups.
created_at: 2025-04-01T20:46:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+	"testing"
+
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/util/leaktest"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCreateCompactionCorePlacements(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	ctx := context.Background()
+	testcases := []struct {
+		name                 string
+		entries              []execinfrapb.RestoreSpanEntry
+		numNodes             int
+		expectedDistribution []int // expected number of entries per node
+	}{
+		{
+			name: "single node, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: roachpb.Key("a"), EndKey: roachpb.Key("b")}},
+			},
+			numNodes:             1,
+			expectedDistribution: []int{1},
+		},
+		{
+			name: "single node, multiple span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: roachpb.Key("a"), EndKey: roachpb.Key("b")}},
+				{Span: roachpb.Span{Key: roachpb.Key("c"), EndKey: roachpb.Key("d")}},
+				{Span: roachpb.Span{Key: roachpb.Key("e"), EndKey: roachpb.Key("f")}},
+			},
path: pkg/backup/compaction_dist_test.go
user: login: kev-cao
body: Done!
created_at: 2025-04-01T23:43:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupinfo"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobsprofiler"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/physicalplan"
+	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/util"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/errors"
+)
+
+// runCompactionPlan creates and runs a distsql plan to compact the spans
+// in the backup chain that need to be compacted. It sends updates from the
+// BulkProcessor to the provided progress channel. It is the caller's
+// responsibility to close the progress channel.
+func runCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	compactChain compactionChain,
+	manifest *backuppb.BackupManifest,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+	progCh chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	log.Infof(
+		ctx, "planning compaction of %d backups: %s",
+		len(compactChain.chainToCompact),
+		util.Map(compactChain.chainToCompact, func(m backuppb.BackupManifest) string {
+			return m.ID.String()
+		}),
+	)
+	backupLocalityMap, err := makeBackupLocalityMap(
+		compactChain.compactedLocalityInfo, execCtx.User(),
+	)
+	if err != nil {
+		return err
+	}
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, manifest.EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+	targetSize := targetRestoreSpanSize.Get(&execCtx.ExecCfg().Settings.SV)
+	maxFiles := maxFileCount.Get(&execCtx.ExecCfg().Settings.SV)
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		manifest.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	spansToCompact, err := getSpansToCompact(
+		ctx, execCtx, manifest, compactChain.chainToCompact, details, defaultStore, kmsEnv,
+	)
+	if err != nil {
+		return err
+	}
+	genSpan := func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error {
+		return errors.Wrap(generateAndSendImportSpans(
+			ctx,
+			spansToCompact,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			spanCh,
+		), "generateAndSendImportSpans")
+	}
+	dsp := execCtx.DistSQLPlanner()
+	plan, planCtx, err := createCompactionPlan(
+		ctx, execCtx, jobID, details, manifest, dsp, genSpan, spansToCompact, targetSize, maxFiles,
+	)
+	if err != nil {
+		return errors.Wrap(err, "creating compaction plan")
+	}
+	sql.FinalizePlan(ctx, planCtx, plan)
+
+	metaFn := func(_ context.Context, meta *execinfrapb.ProducerMetadata) error {
+		if meta.BulkProcessorProgress != nil {
+			progCh <- meta.BulkProcessorProgress
+		}
+
+		// TODO (kev-cao): Add channel for tracing aggregator events.
+		return nil
+	}
+	rowResultWriter := sql.NewRowResultWriter(nil)
+	recv := sql.MakeDistSQLReceiver(
+		ctx,
+		sql.NewMetadataCallbackWriter(rowResultWriter, metaFn),
+		tree.Rows,
+		nil, /* rangeCache */
+		nil, /* txn */
+		nil, /* clockUpdater */
+		execCtx.ExtendedEvalContext().Tracing,
+	)
+	defer recv.Release()
+
+	jobsprofiler.StorePlanDiagram(
+		ctx, execCtx.ExecCfg().DistSQLSrv.Stopper, plan, execCtx.ExecCfg().InternalDB, jobID,
+	)
+
+	evalCtxCopy := execCtx.ExtendedEvalContext().Copy()
+	dsp.Run(ctx, planCtx, nil /* txn */, plan, recv, evalCtxCopy, nil /* finishedSetupFn */)
+	return nil
+}
+
+// createCompactionPlan creates an un-finalized physical plan that will
+// distribute spans from a generator across the cluster for compaction.
+func createCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	manifest *backuppb.BackupManifest,
+	dsp *sql.DistSQLPlanner,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+	targetSize int64,
+	maxFiles int64,
+) (*sql.PhysicalPlan, *sql.PlanningCtx, error) {
+	numEntries, err := countRestoreSpanEntries(ctx, genSpan)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "counting number of restore span entries")
+	}
+
+	// TODO (kev-cao): Add support for execution locality.
+	planCtx, sqlInstanceIDs, err := dsp.SetupAllNodesPlanningWithOracle(
+		ctx, execCtx.ExtendedEvalContext(), execCtx.ExecCfg(),
+		physicalplan.DefaultReplicaChooser, roachpb.Locality{},
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	plan := planCtx.NewPhysicalPlan()
+	corePlacements, err := createCompactionCorePlacements(
+		ctx, jobID, execCtx.User(), details, manifest.ElidedPrefix, genSpan,
+		spansToCompact, sqlInstanceIDs, targetSize, maxFiles, numEntries,
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+	plan.AddNoInputStage(
+		corePlacements,
+		execinfrapb.PostProcessSpec{},
+		[]*types.T{},
+		execinfrapb.Ordering{},
+		nil, /* finalizeLastStageCb */
+	)
+	return plan, planCtx, nil
+}
+
+// countRestoreSpanEntries counts the number of restore span entries that will be
+// be delivered by the given generator.
+func countRestoreSpanEntries(
+	ctx context.Context,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+) (int, error) {
+	countSpansCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var numImportSpans int
+	countTasks := []func(ctx context.Context) error{
+		func(ctx context.Context) error {
+			for range countSpansCh {
+				numImportSpans++
+			}
+			return nil
+		},
+		func(ctx context.Context) error {
+			defer close(countSpansCh)
+			return genSpan(ctx, countSpansCh)
+		},
+	}
+	if err := ctxgroup.GoAndWait(ctx, countTasks...); err != nil {
+		return 0, errors.Wrapf(err, "counting number of spans to compact")
+	}
+	return numImportSpans, nil
+}
+
+// createCompactionCorePlacements takes spans from a generator and evenly
+// distributes them across nodes in the cluster, returning the core placements
+// reflecting that distribution.
+//
+// TODO (kev-cao): This currently distributes the spans so that each node gets
path: pkg/backup/compaction_dist.go
user: login: msbutler
body: the spans outputted by genSpans are split on size as well?
created_at: 2025-04-03T14:31:41Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,336 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupinfo"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobsprofiler"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/physicalplan"
+	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/util"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/errors"
+)
+
+// runCompactionPlan creates and runs a distsql plan to compact the spans
+// in the backup chain that need to be compacted. It sends updates from the
+// BulkProcessor to the provided progress channel. It is the caller's
+// responsibility to close the progress channel.
+//
+//lint:ignore U1000 unused
+func runCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	compactChain compactionChain,
+	manifest *backuppb.BackupManifest,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+	progCh chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	log.Infof(
+		ctx, "planning compaction of %d backups: %s",
+		len(compactChain.chainToCompact),
+		util.Map(compactChain.chainToCompact, func(m backuppb.BackupManifest) string {
+			return m.ID.String()
+		}),
+	)
+	backupLocalityMap, err := makeBackupLocalityMap(
+		compactChain.compactedLocalityInfo, execCtx.User(),
+	)
+	if err != nil {
+		return err
+	}
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, manifest.EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+	targetSize := targetRestoreSpanSize.Get(&execCtx.ExecCfg().Settings.SV)
+	maxFiles := maxFileCount.Get(&execCtx.ExecCfg().Settings.SV)
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		manifest.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	spansToCompact, err := getSpansToCompact(
+		ctx, execCtx, manifest, compactChain.chainToCompact, details, defaultStore, kmsEnv,
+	)
+	if err != nil {
+		return err
+	}
+	genSpan := func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error {
+		return errors.Wrap(generateAndSendImportSpans(
+			ctx,
+			spansToCompact,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			spanCh,
+		), "generateAndSendImportSpans")
+	}
+	dsp := execCtx.DistSQLPlanner()
+	plan, planCtx, err := createCompactionPlan(
+		ctx, execCtx, jobID, details, manifest, dsp, genSpan, spansToCompact, targetSize, maxFiles,
+	)
+	if err != nil {
+		return errors.Wrap(err, "creating compaction plan")
+	}
+	sql.FinalizePlan(ctx, planCtx, plan)
+
+	metaFn := func(_ context.Context, meta *execinfrapb.ProducerMetadata) error {
+		if meta.BulkProcessorProgress != nil {
+			progCh <- meta.BulkProcessorProgress
+		}
+
+		// TODO (kev-cao): Add channel for tracing aggregator events.
+		return nil
+	}
+	rowResultWriter := sql.NewRowResultWriter(nil)
+	recv := sql.MakeDistSQLReceiver(
+		ctx,
+		sql.NewMetadataCallbackWriter(rowResultWriter, metaFn),
+		tree.Rows,
+		nil, /* rangeCache */
+		nil, /* txn */
+		nil, /* clockUpdater */
+		execCtx.ExtendedEvalContext().Tracing,
+	)
+	defer recv.Release()
+
+	jobsprofiler.StorePlanDiagram(
+		ctx, execCtx.ExecCfg().DistSQLSrv.Stopper, plan, execCtx.ExecCfg().InternalDB, jobID,
+	)
+
+	evalCtxCopy := execCtx.ExtendedEvalContext().Copy()
+	dsp.Run(ctx, planCtx, nil /* txn */, plan, recv, evalCtxCopy, nil /* finishedSetupFn */)
+	return nil
+}
+
+// createCompactionPlan creates an un-finalized physical plan that will
+// distribute spans from a generator across the cluster for compaction.
+func createCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	manifest *backuppb.BackupManifest,
+	dsp *sql.DistSQLPlanner,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+	targetSize int64,
+	maxFiles int64,
+) (*sql.PhysicalPlan, *sql.PlanningCtx, error) {
+	numEntries, err := countRestoreSpanEntries(ctx, genSpan)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "counting number of restore span entries")
+	}
+
+	// TODO (kev-cao): Add support for execution locality.
+	planCtx, sqlInstanceIDs, err := dsp.SetupAllNodesPlanningWithOracle(
+		ctx, execCtx.ExtendedEvalContext(), execCtx.ExecCfg(),
+		physicalplan.DefaultReplicaChooser, roachpb.Locality{},
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	plan := planCtx.NewPhysicalPlan()
+	corePlacements, err := createCompactionCorePlacements(
+		ctx, jobID, execCtx.User(), details, manifest.ElidedPrefix, genSpan,
+		spansToCompact, sqlInstanceIDs, targetSize, maxFiles, numEntries,
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+	plan.AddNoInputStage(
+		corePlacements,
+		execinfrapb.PostProcessSpec{},
+		[]*types.T{},
+		execinfrapb.Ordering{},
+		nil, /* finalizeLastStageCb */
+	)
+	return plan, planCtx, nil
+}
+
+// countRestoreSpanEntries counts the number of restore span entries that will be
+// be delivered by the given generator.
+func countRestoreSpanEntries(
+	ctx context.Context,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+) (int, error) {
+	countSpansCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var numImportSpans int
+	countTasks := []func(ctx context.Context) error{
+		func(ctx context.Context) error {
+			for range countSpansCh {
+				numImportSpans++
+			}
+			return nil
+		},
+		func(ctx context.Context) error {
+			defer close(countSpansCh)
+			return genSpan(ctx, countSpansCh)
+		},
+	}
+	if err := ctxgroup.GoAndWait(ctx, countTasks...); err != nil {
+		return 0, errors.Wrapf(err, "counting number of spans to compact")
+	}
+	return numImportSpans, nil
+}
+
+// createCompactionCorePlacements takes spans from a generator and evenly
+// distributes them across nodes in the cluster, returning the core placements
+// reflecting that distribution.
+//
+// TODO (kev-cao): This currently distributes the spans so that each node gets
+// roughly the same number of entries. This could be further improved to take
+// into consideration the size of the spans.
+func createCompactionCorePlacements(
+	ctx context.Context,
+	jobID jobspb.JobID,
+	user username.SQLUsername,
+	details jobspb.BackupDetails,
+	elideMode execinfrapb.ElidePrefix,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+	sqlInstanceIDs []base.SQLInstanceID,
+	targetSize int64,
+	maxFiles int64,
+	numEntries int,
+) ([]physicalplan.ProcessorCorePlacement, error) {
+	numNodes := len(sqlInstanceIDs)
+	corePlacements := make([]physicalplan.ProcessorCorePlacement, numNodes)
+	for i := range corePlacements {
+		corePlacements[i].SQLInstanceID = sqlInstanceIDs[i]
+		corePlacements[i].Core.CompactBackups = &execinfrapb.CompactBackupsSpec{
+			JobID:       int64(jobID),
+			DefaultURI:  details.URI,
+			Destination: details.Destination,
+			Encryption:  details.EncryptionOptions,
+			StartTime:   details.StartTime,
+			EndTime:     details.EndTime,
+			ElideMode:   elideMode,
+			UserProto:   user.EncodeProto(),
+			Spans:       spansToCompact,
+			TargetSize:  targetSize,
+			MaxFiles:    maxFiles,
+		}
+	}
+
+	spanEntryCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var tasks []func(ctx context.Context) error
+	tasks = append(tasks, func(ctx context.Context) error {
+		defer close(spanEntryCh)
+		return genSpan(ctx, spanEntryCh)
+	})
+	tasks = append(tasks, func(ctx context.Context) error {
+		numEntriesPerNode := numEntries / numNodes
+		leftoverEntries := numEntries % numNodes
+		getTargetNumEntries := func(nodeIdx int) int {
+			if nodeIdx < leftoverEntries {
+				// This more evenly distributes the leftover entries across the nodes
+				// after doing integer division to assign the entries to the nodes.
+				return numEntriesPerNode + 1
+			}
+			return numEntriesPerNode
+		}
+		currNode := 0
+		entriesAdded := 0
+		currEntries := roachpb.SpanGroup{}
+		targetNumEntries := getTargetNumEntries(currNode)
+
+		for entry := range spanEntryCh {
+			currEntries.Add(entry.Span)
+			entriesAdded++
+			if entriesAdded == targetNumEntries {
+				corePlacements[currNode].SQLInstanceID = sqlInstanceIDs[currNode]
+				corePlacements[currNode].Core.CompactBackups.AssignedSpans = currEntries.Slice()
+
+				currNode++
+				targetNumEntries = getTargetNumEntries(currNode)
+				currEntries.Clear()
+				entriesAdded = 0
+			}
+			if currNode == numNodes {
+				return nil
+			}
+		}
+		return nil
+	})
+	if err := ctxgroup.GoAndWait(ctx, tasks...); err != nil {
+		return nil, errors.Wrapf(err, "distributing span entries to processors")
+	}
+	return corePlacements, nil
+}
+
+// getSpansToCompact returns all remaining spans the backup manifest that
+// need to be compacted.
+func getSpansToCompact(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	manifest *backuppb.BackupManifest,
+	backupChain []backuppb.BackupManifest,
+	details jobspb.BackupDetails,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+) (roachpb.Spans, error) {
+	var tables []catalog.TableDescriptor
+	for _, desc := range manifest.Descriptors {
+		catDesc := backupinfo.NewDescriptorForManifest(&desc)
+		if table, ok := catDesc.(catalog.TableDescriptor); ok {
+			tables = append(tables, table)
+		}
+	}
+	backupCodec, err := backupinfo.MakeBackupCodec(backupChain)
+	if err != nil {
+		return nil, err
+	}
+	spans, err := spansForAllRestoreTableIndexes(
+		backupCodec,
+		tables,
+		nil,   /* revs */
+		false, /* schemaOnly */
+		false, /* forOnlineRestore */
+	)
+	if err != nil {
+		return nil, err
+	}
+	completedSpans, completedIntroducedSpans, err := getCompletedSpans(
path: pkg/backup/compaction_dist.go
user: login: msbutler
body: just noting for the checkpoint PR: i hope we're checkpointing introduced spans correctly.
created_at: 2025-04-03T14:36:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,209 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+	"testing"
+
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/util/leaktest"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCreateCompactionCorePlacements(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	ctx := context.Background()
+	type Key = roachpb.Key
+	testcases := []struct {
+		name                 string
+		entries              []execinfrapb.RestoreSpanEntry
+		numNodes             int
+		expectedDistribution [][]roachpb.Span
+	}{
+		{
+			name: "single node, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}},
+			},
+		},
+		{
+			name: "single node, multiple span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("c"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{
+					{Key: Key("a"), EndKey: Key("b")},
+					{Key: Key("c"), EndKey: Key("d")},
+					{Key: Key("e"), EndKey: Key("f")},
+				},
+			},
+		},
+		{
+			name: "single node, multiple overlapping span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("b"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("d")}, {Key: Key("e"), EndKey: Key("f")}},
+			},
+		},
+		{
+			name: "multiple nodes, single span entry",
path: pkg/backup/compaction_dist_test.go
user: login: msbutler
body: in the future (no need to change now): for these multi node tests, i think it is sufficient to use 2 nodes, just to reduce the lines of test code, while maintaining the same test coverage.
created_at: 2025-04-03T14:41:51Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,209 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+	"testing"
+
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/util/leaktest"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCreateCompactionCorePlacements(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	ctx := context.Background()
+	type Key = roachpb.Key
+	testcases := []struct {
+		name                 string
+		entries              []execinfrapb.RestoreSpanEntry
+		numNodes             int
+		expectedDistribution [][]roachpb.Span
+	}{
+		{
+			name: "single node, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}},
+			},
+		},
+		{
+			name: "single node, multiple span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
path: pkg/backup/compaction_dist_test.go
user: login: msbutler
body: uber nit: to reduce the chars of code you could use some closures similar to this [test](https://github.com/kev-cao/cockroach/blob/backup/compaction-planning/pkg/crosscluster/physical/stream_ingestion_dist_test.go#L240): so the test case could like: `entries: entries(sp(a,b),sp(c,d)...`


created_at: 2025-04-03T14:45:46Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,209 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+	"testing"
+
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/util/leaktest"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCreateCompactionCorePlacements(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	ctx := context.Background()
+	type Key = roachpb.Key
+	testcases := []struct {
+		name                 string
+		entries              []execinfrapb.RestoreSpanEntry
+		numNodes             int
+		expectedDistribution [][]roachpb.Span
+	}{
+		{
+			name: "single node, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}},
+			},
+		},
+		{
+			name: "single node, multiple span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("c"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{
+					{Key: Key("a"), EndKey: Key("b")},
+					{Key: Key("c"), EndKey: Key("d")},
+					{Key: Key("e"), EndKey: Key("f")},
+				},
+			},
+		},
+		{
+			name: "single node, multiple overlapping span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("b"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("d")}, {Key: Key("e"), EndKey: Key("f")}},
+			},
+		},
+		{
+			name: "multiple nodes, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}},
+				nil,
+				nil,
+			},
+		},
+		{
+			name: "multiple nodes, exact multiple of span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("c"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+				{Span: roachpb.Span{Key: Key("g"), EndKey: Key("h")}},
+				{Span: roachpb.Span{Key: Key("i"), EndKey: Key("j")}},
+				{Span: roachpb.Span{Key: Key("k"), EndKey: Key("l")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}, {Key: Key("c"), EndKey: Key("d")}},
+				{{Key: Key("e"), EndKey: Key("f")}, {Key: Key("g"), EndKey: Key("h")}},
+				{{Key: Key("i"), EndKey: Key("j")}, {Key: Key("k"), EndKey: Key("l")}},
+			},
+		},
+		{
+			name: "multiple nodes, exact multiple of overlapping span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("b"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+				{Span: roachpb.Span{Key: Key("f"), EndKey: Key("h")}},
+				{Span: roachpb.Span{Key: Key("i"), EndKey: Key("j")}},
+				{Span: roachpb.Span{Key: Key("j"), EndKey: Key("l")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("d")}},
+				{{Key: Key("e"), EndKey: Key("h")}},
+				{{Key: Key("i"), EndKey: Key("l")}},
+			},
+		},
+		{
+			name: "multiple nodes, not exact multiple of span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
path: pkg/backup/compaction_dist_test.go
user: login: msbutler
body: could you use 2 2 1 instead of 3 3 2? just trying to reduce the lines of code a reader needs to read.
created_at: 2025-04-03T14:47:25Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,209 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+	"testing"
+
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/util/leaktest"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCreateCompactionCorePlacements(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	ctx := context.Background()
+	type Key = roachpb.Key
+	testcases := []struct {
+		name                 string
+		entries              []execinfrapb.RestoreSpanEntry
+		numNodes             int
+		expectedDistribution [][]roachpb.Span
+	}{
+		{
+			name: "single node, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}},
+			},
+		},
+		{
+			name: "single node, multiple span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("c"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{
+					{Key: Key("a"), EndKey: Key("b")},
+					{Key: Key("c"), EndKey: Key("d")},
+					{Key: Key("e"), EndKey: Key("f")},
+				},
+			},
+		},
+		{
+			name: "single node, multiple overlapping span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("b"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("d")}, {Key: Key("e"), EndKey: Key("f")}},
+			},
+		},
+		{
+			name: "multiple nodes, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}},
+				nil,
+				nil,
+			},
+		},
+		{
+			name: "multiple nodes, exact multiple of span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("c"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+				{Span: roachpb.Span{Key: Key("g"), EndKey: Key("h")}},
+				{Span: roachpb.Span{Key: Key("i"), EndKey: Key("j")}},
+				{Span: roachpb.Span{Key: Key("k"), EndKey: Key("l")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}, {Key: Key("c"), EndKey: Key("d")}},
+				{{Key: Key("e"), EndKey: Key("f")}, {Key: Key("g"), EndKey: Key("h")}},
+				{{Key: Key("i"), EndKey: Key("j")}, {Key: Key("k"), EndKey: Key("l")}},
+			},
+		},
+		{
+			name: "multiple nodes, exact multiple of overlapping span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("b"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+				{Span: roachpb.Span{Key: Key("f"), EndKey: Key("h")}},
+				{Span: roachpb.Span{Key: Key("i"), EndKey: Key("j")}},
+				{Span: roachpb.Span{Key: Key("j"), EndKey: Key("l")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("d")}},
+				{{Key: Key("e"), EndKey: Key("h")}},
+				{{Key: Key("i"), EndKey: Key("l")}},
+			},
+		},
+		{
+			name: "multiple nodes, not exact multiple of span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("c"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+				{Span: roachpb.Span{Key: Key("g"), EndKey: Key("h")}},
+				{Span: roachpb.Span{Key: Key("i"), EndKey: Key("j")}},
+				{Span: roachpb.Span{Key: Key("k"), EndKey: Key("l")}},
+				{Span: roachpb.Span{Key: Key("m"), EndKey: Key("n")}},
+				{Span: roachpb.Span{Key: Key("o"), EndKey: Key("p")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{
+					{Key: Key("a"), EndKey: Key("b")},
+					{Key: Key("c"), EndKey: Key("d")},
+					{Key: Key("e"), EndKey: Key("f")},
+				},
+				{
+					{Key: Key("g"), EndKey: Key("h")},
+					{Key: Key("i"), EndKey: Key("j")},
+					{Key: Key("k"), EndKey: Key("l")},
+				},
+				{
+					{Key: Key("m"), EndKey: Key("n")}, {Key: Key("o"), EndKey: Key("p")},
+				},
+			},
+		},
+		{
+			name: "multiple nodes, not exact multiple of overlapping span entries",
path: pkg/backup/compaction_dist_test.go
user: login: msbutler
body: is this combination really adding more coverage?
created_at: 2025-04-03T14:47:48Z
author_association: COLLABORATOR
diff_hunk: @@ -1016,6 +767,7 @@ func processProgress(
 			manifest.Files = append(manifest.Files, file)
 			manifest.EntryCounts.Add(file.EntryCounts)
 		}
+		// TODO (kev-cao): Add per node progress updates.
path: pkg/backup/compaction_job.go
user: login: msbutler
body: what is this referring to? i think we just need to persist the intermediate manifests, right?
created_at: 2025-04-03T14:50:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,436 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"bytes"
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupencryption"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/backup/backupsink"
+	"github.com/cockroachdb/cockroach/pkg/ccl/storageccl"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/keys"
+	"github.com/cockroachdb/cockroach/pkg/kv/kvpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowexec"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/storage"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/cockroach/pkg/util/stop"
+	"github.com/cockroachdb/errors"
+	"github.com/cockroachdb/logtags"
+	gogotypes "github.com/gogo/protobuf/types"
+)
+
+const (
+	compactBackupsProcessorName = "compactBackupsProcessor"
+)
+
+type compactBackupsProcessor struct {
+	execinfra.ProcessorBase
+
+	spec execinfrapb.CompactBackupsSpec
+
+	progCh                 chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress
+	cancelAndWaitForWorker func()
+	compactionErr          error
+
+	// completedSpans tracks how many spans have been successfully compacted by
+	// the backup processor.
+	completedSpans int32
+}
+
+var (
+	_ execinfra.Processor = &compactBackupsProcessor{}
+	_ execinfra.RowSource = &compactBackupsProcessor{}
+)
+
+func newCompactBackupsProcessor(
+	ctx context.Context,
+	flowCtx *execinfra.FlowCtx,
+	processorID int32,
+	spec execinfrapb.CompactBackupsSpec,
+	post *execinfrapb.PostProcessSpec,
+) (execinfra.Processor, error) {
+	processor := &compactBackupsProcessor{
+		spec:   spec,
+		progCh: make(chan execinfrapb.RemoteProducerMetadata_BulkProcessorProgress),
+	}
+	if err := processor.Init(ctx, processor, post, []*types.T{}, flowCtx, processorID, nil, /* memMonitor */
+		execinfra.ProcStateOpts{
+			InputsToDrain: nil,
+			TrailingMetaCallback: func() []execinfrapb.ProducerMetadata {
+				processor.close()
+				return nil
+			},
+		}); err != nil {
+		return nil, err
+	}
+	return processor, nil
+}
+
+func (p *compactBackupsProcessor) Start(ctx context.Context) {
+	ctx = logtags.AddTag(ctx, "job", p.spec.JobID)
+
+	p.StartInternal(ctx, compactBackupsProcessorName)
+	ctx, cancel := context.WithCancel(ctx)
+	p.cancelAndWaitForWorker = func() {
+		cancel()
+		for range p.progCh {
+		}
+	}
+	log.Infof(ctx, "starting backup compaction")
+	if err := p.FlowCtx.Stopper().RunAsyncTaskEx(ctx, stop.TaskOpts{
+		TaskName: compactBackupsProcessorName + ".runCompactBackups",
+		SpanOpt:  stop.ChildSpan,
+	}, func(ctx context.Context) {
+		p.compactionErr = p.runCompactBackups(ctx)
+		cancel()
+		close(p.progCh)
+	}); err != nil {
+		p.compactionErr = err
+		cancel()
+		close(p.progCh)
+	}
+}
+
+func (p *compactBackupsProcessor) Next() (rowenc.EncDatumRow, *execinfrapb.ProducerMetadata) {
+	if p.State != execinfra.StateRunning {
+		return nil, p.DrainHelper()
+	}
+
+	prog, ok := <-p.progCh
+	if !ok {
+		p.MoveToDraining(p.compactionErr)
+		return nil, p.DrainHelper()
+	}
+	return nil, p.constructProgressProducerMeta(prog)
+}
+
+// constructProgressProducerMeta takes the BulkProcessorProgress message and
+// converts it into a ProducerMetadata message.
+func (p *compactBackupsProcessor) constructProgressProducerMeta(
+	prog execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) *execinfrapb.ProducerMetadata {
+	prog.NodeID = p.FlowCtx.NodeID.SQLInstanceID()
+	prog.FlowID = p.FlowCtx.ID
+
+	progDetails := backuppb.BackupManifest_Progress{}
+	if err := gogotypes.UnmarshalAny(&prog.ProgressDetails, &progDetails); err != nil {
+		log.Warningf(p.Ctx(), "failed to unmarshal progress details: %v", err)
+	} else {
+		p.completedSpans += progDetails.CompletedSpans
+	}
+
+	return &execinfrapb.ProducerMetadata{BulkProcessorProgress: &prog}
+}
+
+func (p *compactBackupsProcessor) close() {
+	if p.Closed {
+		return
+	}
+	if p.cancelAndWaitForWorker != nil {
+		p.cancelAndWaitForWorker()
+	}
+	p.InternalClose()
+}
+
+// ConsumerClosed is part of the RowSource interface. We have to override the
+// implementation provided by ProcessorBase.
+func (p *compactBackupsProcessor) ConsumerClosed() {
+	p.close()
+}
+
+// runCompactBackups is the main entry point for the backup compaction processor.
+func (p *compactBackupsProcessor) runCompactBackups(ctx context.Context) error {
+	if len(p.spec.AssignedSpans) == 0 {
+		return nil
+	}
+	user := p.spec.User()
+	execCfg, ok := p.FlowCtx.Cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	if !ok {
+		return errors.New("executor config is not of type sql.ExecutorConfig")
+	}
+	defaultConf, err := cloud.ExternalStorageConfFromURI(p.spec.DefaultURI, user)
+	if err != nil {
+		return errors.Wrapf(err, "export configuration")
+	}
+	defaultStore, err := execCfg.DistSQLSrv.ExternalStorage(ctx, defaultConf)
+	if err != nil {
+		return errors.Wrapf(err, "external storage")
+	}
+
+	compactChain, encryption, err := p.compactionChainFromSpec(ctx, execCfg, user)
+	if err != nil {
+		return err
+	}
+
+	backupLocalityMap, err := makeBackupLocalityMap(compactChain.compactedLocalityInfo, p.spec.User())
+	if err != nil {
+		return err
+	}
+
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, compactChain.lastBackup().EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		p.spec.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		p.spec.TargetSize,
+		p.spec.MaxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	genSpan := func(ctx context.Context, entryCh chan execinfrapb.RestoreSpanEntry) error {
+		return errors.Wrapf(generateAndSendImportSpans(
+			ctx,
+			p.spec.Spans,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			entryCh,
+		), "generate and send import spans")
+	}
+
+	entryCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	tasks := []func(context.Context) error{
+		func(ctx context.Context) error {
+			defer close(entryCh)
+			return genSpan(ctx, entryCh)
+		},
+		func(ctx context.Context) error {
+			return p.processSpanEntries(
+				ctx, execCfg, entryCh, encryption, defaultStore,
+			)
+		},
+	}
+
+	return ctxgroup.GoAndWait(ctx, tasks...)
+}
+
+// processSpanEntries pulls RestoreSpanEntries from entryCh and if they are
+// assigned to the processor, performs compaction on them.
+func (p *compactBackupsProcessor) processSpanEntries(
+	ctx context.Context,
+	execCfg *sql.ExecutorConfig,
+	entryCh chan execinfrapb.RestoreSpanEntry,
+	encryption *jobspb.BackupEncryptionOptions,
+	store cloud.ExternalStorage,
+) error {
+	var fileEncryption *kvpb.FileEncryptionOptions
+	if encryption != nil {
+		fileEncryption = &kvpb.FileEncryptionOptions{Key: encryption.Key}
+	}
+	sinkConf := backupsink.SSTSinkConf{
+		ID:        execCfg.DistSQLSrv.NodeID.SQLInstanceID(),
+		Enc:       fileEncryption,
+		ProgCh:    p.progCh,
+		Settings:  &execCfg.Settings.SV,
+		ElideMode: p.spec.ElideMode,
+	}
+	sink, err := backupsink.MakeSSTSinkKeyWriter(sinkConf, store, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := sink.Flush(ctx); err != nil {
+			log.Warningf(ctx, "failed to flush sink: %v", err)
+			logClose(ctx, sink, "SST sink")
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case entry, ok := <-entryCh:
+			if !ok {
+				return nil
+			}
+			if assigned, err := p.isAssignedEntry(entry); err != nil {
+				return err
+			} else if !assigned {
+				continue
+			}
+			sstIter, err := openSSTs(ctx, execCfg, entry, fileEncryption, p.spec.EndTime)
+			if err != nil {
+				return errors.Wrap(err, "opening SSTs")
+			}
+
+			if err := compactSpanEntry(ctx, sstIter, sink); err != nil {
+				return errors.Wrap(err, "compacting span entry")
+			}
+		}
+	}
+}
+
+// isAssignedEntry checks if a RestoreSpanEntry is assigned to be compacted by
+// this processor. We expect that if an entry is assigned to a processor, it is
+// to be assigned in its entirety. Otherwise, an error is returned.
+func (p *compactBackupsProcessor) isAssignedEntry(
+	entry execinfrapb.RestoreSpanEntry,
+) (bool, error) {
+	for _, assignedSpan := range p.spec.AssignedSpans {
+		if assignedSpan.Contains(entry.Span) {
+			return true, nil
+		} else if assignedSpan.Overlaps(entry.Span) {
+			// If a RestoreSpanEntry overlaps with an assigned span but is not contained
+			// within it, that implies that multiple processors will attempt to compact
+			// the restore entry. This is an error as it will result in some conflicting
+			// span to SST file mappings in the manifest.
+			return false, errors.Newf(
path: pkg/backup/compaction_processor.go
user: login: msbutler
body: mark this as an assertion failure?
created_at: 2025-04-03T14:52:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,334 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+
+	"github.com/cockroachdb/cockroach/pkg/backup/backupinfo"
+	"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/cloud"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobsprofiler"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/physicalplan"
+	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
+	"github.com/cockroachdb/cockroach/pkg/sql/types"
+	"github.com/cockroachdb/cockroach/pkg/util"
+	"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/cockroachdb/errors"
+)
+
+// runCompactionPlan creates and runs a distsql plan to compact the spans
+// in the backup chain that need to be compacted. It sends updates from the
+// BulkProcessor to the provided progress channel. It is the caller's
+// responsibility to close the progress channel.
+func runCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	compactChain compactionChain,
+	manifest *backuppb.BackupManifest,
+	defaultStore cloud.ExternalStorage,
+	kmsEnv cloud.KMSEnv,
+	progCh chan *execinfrapb.RemoteProducerMetadata_BulkProcessorProgress,
+) error {
+	log.Infof(
+		ctx, "planning compaction of %d backups: %s",
+		len(compactChain.chainToCompact),
+		util.Map(compactChain.chainToCompact, func(m backuppb.BackupManifest) string {
+			return m.ID.String()
+		}),
+	)
+	backupLocalityMap, err := makeBackupLocalityMap(
+		compactChain.compactedLocalityInfo, execCtx.User(),
+	)
+	if err != nil {
+		return err
+	}
+	introducedSpanFrontier, err := createIntroducedSpanFrontier(
+		compactChain.backupChain, manifest.EndTime,
+	)
+	if err != nil {
+		return err
+	}
+	defer introducedSpanFrontier.Release()
+	targetSize := targetRestoreSpanSize.Get(&execCtx.ExecCfg().Settings.SV)
+	maxFiles := maxFileCount.Get(&execCtx.ExecCfg().Settings.SV)
+	var fsc fileSpanComparator = &exclusiveEndKeyComparator{}
+	filter, err := makeSpanCoveringFilter(
+		manifest.Spans,
+		[]jobspb.RestoreProgress_FrontierEntry{},
+		introducedSpanFrontier,
+		targetSize,
+		maxFiles,
+	)
+	if err != nil {
+		return err
+	}
+
+	spansToCompact, err := getSpansToCompact(
+		ctx, execCtx, manifest, compactChain.chainToCompact, details, defaultStore, kmsEnv,
+	)
+	if err != nil {
+		return err
+	}
+	genSpan := func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error {
+		return errors.Wrap(generateAndSendImportSpans(
+			ctx,
+			spansToCompact,
+			compactChain.chainToCompact,
+			compactChain.compactedIterFactory,
+			backupLocalityMap,
+			filter,
+			fsc,
+			spanCh,
+		), "generateAndSendImportSpans")
+	}
+	dsp := execCtx.DistSQLPlanner()
+	plan, planCtx, err := createCompactionPlan(
+		ctx, execCtx, jobID, details, manifest, dsp, genSpan, spansToCompact, targetSize, maxFiles,
+	)
+	if err != nil {
+		return errors.Wrap(err, "creating compaction plan")
+	}
+	sql.FinalizePlan(ctx, planCtx, plan)
+
+	metaFn := func(_ context.Context, meta *execinfrapb.ProducerMetadata) error {
+		if meta.BulkProcessorProgress != nil {
+			progCh <- meta.BulkProcessorProgress
+		}
+
+		// TODO (kev-cao): Add channel for tracing aggregator events.
+		return nil
+	}
+	rowResultWriter := sql.NewRowResultWriter(nil)
+	recv := sql.MakeDistSQLReceiver(
+		ctx,
+		sql.NewMetadataCallbackWriter(rowResultWriter, metaFn),
+		tree.Rows,
+		nil, /* rangeCache */
+		nil, /* txn */
+		nil, /* clockUpdater */
+		execCtx.ExtendedEvalContext().Tracing,
+	)
+	defer recv.Release()
+
+	jobsprofiler.StorePlanDiagram(
+		ctx, execCtx.ExecCfg().DistSQLSrv.Stopper, plan, execCtx.ExecCfg().InternalDB, jobID,
+	)
+
+	evalCtxCopy := execCtx.ExtendedEvalContext().Copy()
+	dsp.Run(ctx, planCtx, nil /* txn */, plan, recv, evalCtxCopy, nil /* finishedSetupFn */)
+	return nil
+}
+
+// createCompactionPlan creates an un-finalized physical plan that will
+// distribute spans from a generator across the cluster for compaction.
+func createCompactionPlan(
+	ctx context.Context,
+	execCtx sql.JobExecContext,
+	jobID jobspb.JobID,
+	details jobspb.BackupDetails,
+	manifest *backuppb.BackupManifest,
+	dsp *sql.DistSQLPlanner,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+	spansToCompact roachpb.Spans,
+	targetSize int64,
+	maxFiles int64,
+) (*sql.PhysicalPlan, *sql.PlanningCtx, error) {
+	numEntries, err := countRestoreSpanEntries(ctx, genSpan)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "counting number of restore span entries")
+	}
+
+	// TODO (kev-cao): Add support for execution locality.
+	planCtx, sqlInstanceIDs, err := dsp.SetupAllNodesPlanningWithOracle(
+		ctx, execCtx.ExtendedEvalContext(), execCtx.ExecCfg(),
+		physicalplan.DefaultReplicaChooser, roachpb.Locality{},
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	plan := planCtx.NewPhysicalPlan()
+	corePlacements, err := createCompactionCorePlacements(
+		ctx, jobID, execCtx.User(), details, manifest.ElidedPrefix, genSpan,
+		spansToCompact, sqlInstanceIDs, targetSize, maxFiles, numEntries,
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+	plan.AddNoInputStage(
+		corePlacements,
+		execinfrapb.PostProcessSpec{},
+		[]*types.T{},
+		execinfrapb.Ordering{},
+		nil, /* finalizeLastStageCb */
+	)
+	return plan, planCtx, nil
+}
+
+// countRestoreSpanEntries counts the number of restore span entries that will be
+// be delivered by the given generator.
+func countRestoreSpanEntries(
+	ctx context.Context,
+	genSpan func(ctx context.Context, spanCh chan execinfrapb.RestoreSpanEntry) error,
+) (int, error) {
+	countSpansCh := make(chan execinfrapb.RestoreSpanEntry, 1000)
+	var numImportSpans int
+	countTasks := []func(ctx context.Context) error{
+		func(ctx context.Context) error {
+			for range countSpansCh {
+				numImportSpans++
+			}
+			return nil
+		},
+		func(ctx context.Context) error {
+			defer close(countSpansCh)
+			return genSpan(ctx, countSpansCh)
+		},
+	}
+	if err := ctxgroup.GoAndWait(ctx, countTasks...); err != nil {
+		return 0, errors.Wrapf(err, "counting number of spans to compact")
+	}
+	return numImportSpans, nil
+}
+
+// createCompactionCorePlacements takes spans from a generator and evenly
+// distributes them across nodes in the cluster, returning the core placements
+// reflecting that distribution.
+//
+// TODO (kev-cao): This currently distributes the spans so that each node gets
path: pkg/backup/compaction_dist.go
user: login: kev-cao
body: > // Additionally, if targetSize > 0, we can
// merge the current cover span with the previous cover span if the merged set
// of files have a total data size below the target size.

Oh you're right, well that's nice of it
created_at: 2025-04-03T19:16:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,209 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+	"testing"
+
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/util/leaktest"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCreateCompactionCorePlacements(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	ctx := context.Background()
+	type Key = roachpb.Key
+	testcases := []struct {
+		name                 string
+		entries              []execinfrapb.RestoreSpanEntry
+		numNodes             int
+		expectedDistribution [][]roachpb.Span
+	}{
+		{
+			name: "single node, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}},
+			},
+		},
+		{
+			name: "single node, multiple span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("c"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{
+					{Key: Key("a"), EndKey: Key("b")},
+					{Key: Key("c"), EndKey: Key("d")},
+					{Key: Key("e"), EndKey: Key("f")},
+				},
+			},
+		},
+		{
+			name: "single node, multiple overlapping span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("b"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("d")}, {Key: Key("e"), EndKey: Key("f")}},
+			},
+		},
+		{
+			name: "multiple nodes, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}},
+				nil,
+				nil,
+			},
+		},
+		{
+			name: "multiple nodes, exact multiple of span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("c"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+				{Span: roachpb.Span{Key: Key("g"), EndKey: Key("h")}},
+				{Span: roachpb.Span{Key: Key("i"), EndKey: Key("j")}},
+				{Span: roachpb.Span{Key: Key("k"), EndKey: Key("l")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}, {Key: Key("c"), EndKey: Key("d")}},
+				{{Key: Key("e"), EndKey: Key("f")}, {Key: Key("g"), EndKey: Key("h")}},
+				{{Key: Key("i"), EndKey: Key("j")}, {Key: Key("k"), EndKey: Key("l")}},
+			},
+		},
+		{
+			name: "multiple nodes, exact multiple of overlapping span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("b"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+				{Span: roachpb.Span{Key: Key("f"), EndKey: Key("h")}},
+				{Span: roachpb.Span{Key: Key("i"), EndKey: Key("j")}},
+				{Span: roachpb.Span{Key: Key("j"), EndKey: Key("l")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("d")}},
+				{{Key: Key("e"), EndKey: Key("h")}},
+				{{Key: Key("i"), EndKey: Key("l")}},
+			},
+		},
+		{
+			name: "multiple nodes, not exact multiple of span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+				{Span: roachpb.Span{Key: Key("c"), EndKey: Key("d")}},
+				{Span: roachpb.Span{Key: Key("e"), EndKey: Key("f")}},
+				{Span: roachpb.Span{Key: Key("g"), EndKey: Key("h")}},
+				{Span: roachpb.Span{Key: Key("i"), EndKey: Key("j")}},
+				{Span: roachpb.Span{Key: Key("k"), EndKey: Key("l")}},
+				{Span: roachpb.Span{Key: Key("m"), EndKey: Key("n")}},
+				{Span: roachpb.Span{Key: Key("o"), EndKey: Key("p")}},
+			},
+			numNodes: 3,
+			expectedDistribution: [][]roachpb.Span{
+				{
+					{Key: Key("a"), EndKey: Key("b")},
+					{Key: Key("c"), EndKey: Key("d")},
+					{Key: Key("e"), EndKey: Key("f")},
+				},
+				{
+					{Key: Key("g"), EndKey: Key("h")},
+					{Key: Key("i"), EndKey: Key("j")},
+					{Key: Key("k"), EndKey: Key("l")},
+				},
+				{
+					{Key: Key("m"), EndKey: Key("n")}, {Key: Key("o"), EndKey: Key("p")},
+				},
+			},
+		},
+		{
+			name: "multiple nodes, not exact multiple of overlapping span entries",
path: pkg/backup/compaction_dist_test.go
user: login: kev-cao
body: I suppose not — it did have one nice test cover where some of the spans assigned to a node are overlapped while some are not, but I'll just move that into the other overlap test.
created_at: 2025-04-03T19:20:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1016,6 +767,7 @@ func processProgress(
 			manifest.Files = append(manifest.Files, file)
 			manifest.EntryCounts.Add(file.EntryCounts)
 		}
+		// TODO (kev-cao): Add per node progress updates.
path: pkg/backup/compaction_job.go
user: login: kev-cao
body: I noticed that `backup_job` has [progress updates for each node](https://github.com/kev-cao/cockroach/blob/673c8c1796855bf2f729a1f9106a24217713d3d2/pkg/backup/backup_job.go#L231-L244), that seemed like a nice to have for compactions too.
created_at: 2025-04-03T19:23:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,209 @@
+// Copyright 2025 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+package backup
+
+import (
+	"context"
+	"testing"
+
+	"github.com/cockroachdb/cockroach/pkg/base"
+	"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
+	"github.com/cockroachdb/cockroach/pkg/roachpb"
+	"github.com/cockroachdb/cockroach/pkg/security/username"
+	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/util/leaktest"
+	"github.com/cockroachdb/cockroach/pkg/util/log"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCreateCompactionCorePlacements(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	ctx := context.Background()
+	type Key = roachpb.Key
+	testcases := []struct {
+		name                 string
+		entries              []execinfrapb.RestoreSpanEntry
+		numNodes             int
+		expectedDistribution [][]roachpb.Span
+	}{
+		{
+			name: "single node, single span entry",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
+			},
+			numNodes: 1,
+			expectedDistribution: [][]roachpb.Span{
+				{{Key: Key("a"), EndKey: Key("b")}},
+			},
+		},
+		{
+			name: "single node, multiple span entries",
+			entries: []execinfrapb.RestoreSpanEntry{
+				{Span: roachpb.Span{Key: Key("a"), EndKey: Key("b")}},
path: pkg/backup/compaction_dist_test.go
user: login: kev-cao
body: Updated! Looks a lot more readable now for sure
created_at: 2025-04-03T19:33:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: cockroach-teamcity
created_at: 2025-03-31T21:24:30Z
body: 

<!-- Reviewable:start -->
This change is [<img src="https://reviewable.io/review_button.svg" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/cockroachdb/cockroach/143769)
<!-- Reviewable:end -->

author_association: MEMBER
user: login: blathers-crl[bot]
created_at: 2025-04-01T15:36:15Z
body: Your pull request contains more than 1000 changes. It is strongly encouraged to split big PRs into smaller chunks.

<sub>:owl: Hoot! I am a [Blathers](https://github.com/apps/blathers-crl), a bot for [CockroachDB](https://github.com/cockroachdb). My owner is [dev-inf](https://github.com/orgs/cockroachdb/teams/dev-inf).</sub>
author_association: NONE
user: login: kev-cao
created_at: 2025-04-04T02:15:53Z
body: TFTR!

bors r=msbutler
author_association: CONTRIBUTOR
user: login: craig[bot]
created_at: 2025-04-04T03:31:11Z
body: Build succeeded:
  * [unit_tests](https://github.com/cockroachdb/cockroach/actions/runs/14256878244/job/39961044145)
  * [acceptance](https://github.com/cockroachdb/cockroach/actions/runs/14256878244/job/39961042855)
  * [linux_amd64_build](https://github.com/cockroachdb/cockroach/actions/runs/14256878244/job/39961043585)
  * [examples_orms](https://github.com/cockroachdb/cockroach/actions/runs/14256878244/job/39961043279)
  * [docker_image_amd64](https://github.com/cockroachdb/cockroach/actions/runs/14256878244/job/39961043152)
  * [check_generated_code](https://github.com/cockroachdb/cockroach/actions/runs/14256878244/job/39961043012)
  * [lint](https://github.com/cockroachdb/cockroach/actions/runs/14256878244/job/39961043455)
  * [local_roachtest](https://github.com/cockroachdb/cockroach/actions/runs/14256878244/job/39961043886)
  * [local_roachtest_fips](https://github.com/cockroachdb/cockroach/actions/runs/14256878244/job/39961044010)
  * [linux_amd64_fips_build](https://github.com/cockroachdb/cockroach/actions/runs/14256878244/job/39961043754)
author_association: CONTRIBUTOR