This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 3fb910983 fix(compaction): rewrite complete source files (#2288)
3fb910983 is described below
commit 3fb910983b9674efc6092418af90a1c2b821dd42
Author: Matt Faltyn <[email protected]>
AuthorDate: Mon Oct 26 08:38:46 2026 -0700
fix(compaction): rewrite complete source files (#2288)
* fix(compaction): rewrite complete source files
Signed-off-by: Matt Faltyn <[email protected]>
* test(compaction): format validation callbacks
Signed-off-by: Matt Faltyn <[email protected]>
* fix(compaction): validate split metadata and clear residuals
Signed-off-by: Matt Faltyn <[email protected]>
---------
Signed-off-by: Matt Faltyn <[email protected]>
---
table/compaction/compaction.go | 50 ++++++-
table/compaction/compaction_file_grain_test.go | 163 +++++++++++++++++++++++
table/compaction_file_grain_test.go | 173 +++++++++++++++++++++++++
table/rewrite_data_files.go | 25 ++++
website/src/api.md | 2 +
5 files changed, 410 insertions(+), 3 deletions(-)
diff --git a/table/compaction/compaction.go b/table/compaction/compaction.go
index 31c8ba8e5..c1c5d026f 100644
--- a/table/compaction/compaction.go
+++ b/table/compaction/compaction.go
@@ -21,6 +21,8 @@ package compaction
import (
"bytes"
"fmt"
+ "reflect"
+ "slices"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/internal"
@@ -188,16 +190,44 @@ type Plan struct {
// PlanCompaction analyzes the given scan tasks and produces a Plan.
// Tasks are grouped by partition, classified as candidates or skipped,
// and candidates are bin-packed into groups targeting TargetFileSizeBytes.
+// Tasks should come from one coherent scan plan; split tasks for the same file
+// are collapsed, retaining the first task's per-file delete and lineage data.
+// Residual filters select files to rewrite, but are not applied to file
contents.
func (cfg Config) PlanCompaction(tasks []table.FileScanTask) (Plan, error) {
if err := cfg.Validate(); err != nil {
return Plan{}, fmt.Errorf("invalid compaction config: %w", err)
}
+ // Compaction operates on whole physical files. A scan planner may
split a
+ // file into multiple tasks, so collapse tasks by path before counting,
+ // selecting, or packing them. Keep the first task's delete and lineage
+ // metadata while replacing its range with the complete file range.
+ wholeFileTasks := make([]table.FileScanTask, 0, len(tasks))
+ seenFiles := make(map[string]table.FileScanTask, len(tasks))
+ for _, task := range tasks {
+ if task.File == nil {
+ return Plan{}, fmt.Errorf("%w: compaction task has no
data file", table.ErrInvalidOperation)
+ }
+ path := task.File.FilePath()
+ if first, ok := seenFiles[path]; ok {
+ if !sameCompactionMetadata(first, task) {
+ return Plan{}, fmt.Errorf("%w: split tasks for
%q have different delete or lineage metadata", table.ErrInvalidOperation, path)
+ }
+
+ continue
+ }
+ seenFiles[path] = task
+ task.Start = 0
+ task.Length = task.File.FileSizeBytes()
+ task.Residual = nil
+ wholeFileTasks = append(wholeFileTasks, task)
+ }
+
plan := Plan{
- TotalInputFiles: len(tasks),
+ TotalInputFiles: len(wholeFileTasks),
}
- for _, t := range tasks {
+ for _, t := range wholeFileTasks {
plan.TotalInputBytes += t.File.FileSizeBytes()
}
@@ -211,7 +241,7 @@ func (cfg Config) PlanCompaction(tasks
[]table.FileScanTask) (Plan, error) {
partitionIndices := make(map[string]int)
var partitionBuckets []partitionBucket
- for _, t := range tasks {
+ for _, t := range wholeFileTasks {
if !cfg.isCandidate(t) {
plan.SkippedFiles++
@@ -273,6 +303,20 @@ func (cfg Config) PlanCompaction(tasks
[]table.FileScanTask) (Plan, error) {
return plan, nil
}
+func sameCompactionMetadata(a, b table.FileScanTask) bool {
+ equalDataFiles := func(a, b []iceberg.DataFile) bool {
+ return slices.EqualFunc(a, b, func(a, b iceberg.DataFile) bool {
+ return reflect.DeepEqual(a, b)
+ })
+ }
+
+ return reflect.DeepEqual(a.FirstRowID, b.FirstRowID) &&
+ reflect.DeepEqual(a.DataSequenceNumber, b.DataSequenceNumber) &&
+ equalDataFiles(a.DeleteFiles, b.DeleteFiles) &&
+ equalDataFiles(a.EqualityDeleteFiles, b.EqualityDeleteFiles) &&
+ equalDataFiles(a.DeletionVectorFiles, b.DeletionVectorFiles)
+}
+
// isCandidate returns true if a file should be considered for compaction.
func (cfg Config) isCandidate(t table.FileScanTask) bool {
size := t.File.FileSizeBytes()
diff --git a/table/compaction/compaction_file_grain_test.go
b/table/compaction/compaction_file_grain_test.go
new file mode 100644
index 000000000..40a57193c
--- /dev/null
+++ b/table/compaction/compaction_file_grain_test.go
@@ -0,0 +1,163 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package compaction_test
+
+import (
+ "testing"
+
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/table"
+ "github.com/apache/iceberg-go/table/compaction"
+ "github.com/stretchr/testify/require"
+)
+
+type offsetDataFile struct {
+ iceberg.DataFile
+ offset int64
+}
+
+func (f offsetDataFile) ContentOffset() *int64 { return &f.offset }
+
+func TestPlanCompactionCountsWholeFiles(t *testing.T) {
+ file := newDataFile("split.parquet", 10)
+ firstRowID, sequence := int64(20), int64(3)
+ first := makeTask(file, 1, 1)
+ first.DeletionVectorFiles =
[]iceberg.DataFile{newScopedDeleteFile("delete.puffin", file.path, 1)}
+ first.FirstRowID, first.DataSequenceNumber = &firstRowID, &sequence
+ first.Residual = iceberg.EqualTo(iceberg.Reference("id"), int64(1))
+ first.Length = file.size / 2
+ second := first
+ second.Start, second.Length = first.Length, file.size-first.Length
+ second.FirstRowID = new(int64)
+ *second.FirstRowID = firstRowID
+ second.DataSequenceNumber = new(int64)
+ *second.DataSequenceNumber = sequence
+ second.DeletionVectorFiles =
[]iceberg.DataFile{newScopedDeleteFile("delete.puffin", file.path, 1)}
+ duplicate := *file
+ first.File = &duplicate
+ tasks := []table.FileScanTask{second, first,
makeTask(newDataFile("other.parquet", 10), 0, 0)}
+ original := append([]table.FileScanTask(nil), tasks...)
+
+ cfg := compaction.DefaultConfig()
+ cfg.MinInputFiles = 1
+ plan, err := cfg.PlanCompaction(tasks)
+ require.NoError(t, err)
+ require.Equal(t, 2, plan.TotalInputFiles)
+ require.Equal(t, 2*file.size, plan.TotalInputBytes)
+ require.Len(t, plan.Groups, 1)
+ require.Len(t, plan.Groups[0].Tasks, 2)
+ require.Equal(t, 2*file.size, plan.Groups[0].TotalSizeBytes)
+ require.Equal(t, 3, plan.Groups[0].DeleteFileCount)
+ got := plan.Groups[0].Tasks[0]
+ require.Same(t, second.File, got.File)
+ require.Same(t, second.FirstRowID, got.FirstRowID)
+ require.Same(t, second.DataSequenceNumber, got.DataSequenceNumber)
+ require.Same(t, second.DeletionVectorFiles[0],
got.DeletionVectorFiles[0])
+ require.Equal(t, int64(0), got.Start)
+ require.Equal(t, file.size, got.Length)
+ require.Nil(t, got.Residual, "compaction rewrites all rows in the
selected file")
+ require.Equal(t, original, tasks, "normalization must not mutate caller
tasks")
+}
+
+func TestPlanCompactionRejectsConflictingSplitMetadata(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ change func(*table.FileScanTask)
+ }{
+ {"first row ID", func(task *table.FileScanTask) { value :=
int64(21); task.FirstRowID = &value }},
+ {"missing first row ID", func(task *table.FileScanTask) {
task.FirstRowID = nil }},
+ {"data sequence number", func(task *table.FileScanTask) { value
:= int64(4); task.DataSequenceNumber = &value }},
+ {"missing data sequence number", func(task *table.FileScanTask)
{ task.DataSequenceNumber = nil }},
+ {"positional deletes", func(task *table.FileScanTask) {
task.DeleteFiles = []iceberg.DataFile{newDeleteFile("other.pos")} }},
+ {"same delete path, different metadata", func(task
*table.FileScanTask) {
+ task.DeleteFiles =
[]iceberg.DataFile{newDeleteFile("same.pos")}
+ }},
+ {"delete content offset", func(task *table.FileScanTask) {
+ base := newDeleteFile("same.puffin")
+ task.DeleteFiles =
[]iceberg.DataFile{offsetDataFile{DataFile: base, offset: 2}}
+ }},
+ {"equality deletes", func(task *table.FileScanTask) {
+ task.EqualityDeleteFiles =
[]iceberg.DataFile{newDeleteFile("other.eq")}
+ }},
+ {"deletion vectors", func(task *table.FileScanTask) {
+ task.DeletionVectorFiles =
[]iceberg.DataFile{newScopedDeleteFile("other.puffin", "split.parquet", 1)}
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ file := newDataFile("split.parquet", 10)
+ first := makeTask(file, 0, 0)
+ first.DeleteFiles = nil
+ if tc.name == "same delete path, different metadata" {
+ first.DeleteFiles =
[]iceberg.DataFile{newDeleteFile("same.pos")}
+ first.DeleteFiles[0] = &testDataFile{path:
"same.pos", size: 1024, records: 5, content: iceberg.EntryContentPosDeletes}
+ }
+ first.FirstRowID, first.DataSequenceNumber =
new(int64), new(int64)
+ if tc.name == "delete content offset" {
+ first.DeleteFiles =
[]iceberg.DataFile{offsetDataFile{DataFile: newDeleteFile("same.puffin"),
offset: 1}}
+ }
+ second := first
+ second.Start, second.Length = file.size/2, file.size/2
+ tc.change(&second)
+ _, err :=
compaction.DefaultConfig().PlanCompaction([]table.FileScanTask{first, second})
+ require.ErrorIs(t, err, table.ErrInvalidOperation)
+ })
+ }
+}
+
+func TestPlanCompactionTreatsNilAndEmptyDeleteListsEqually(t *testing.T) {
+ file := newDataFile("split.parquet", 10)
+ first := makeTask(file, 0, 0)
+ first.DeleteFiles = nil
+ second := first
+ second.Start, second.Length = file.size/2, file.size/2
+ second.DeleteFiles = []iceberg.DataFile{}
+ _, err :=
compaction.DefaultConfig().PlanCompaction([]table.FileScanTask{first, second})
+ require.NoError(t, err)
+}
+
+func TestPlanCompactionDoesNotCountRangesTowardMinimumFiles(t *testing.T) {
+ file := newDataFile("split.parquet", 10)
+ tasks := make([]table.FileScanTask, 5)
+ for i := range tasks {
+ tasks[i] = table.FileScanTask{File: file, Start: int64(i) *
file.size / 5, Length: file.size / 5}
+ }
+ plan, err := compaction.DefaultConfig().PlanCompaction(tasks)
+ require.NoError(t, err)
+ require.Equal(t, 1, plan.TotalInputFiles)
+ require.Equal(t, file.size, plan.TotalInputBytes)
+ require.Equal(t, 1, plan.SkippedFiles)
+ require.Empty(t, plan.Groups, "five ranges are not five source files")
+}
+
+func TestPlanCompactionCountsSkippedFileOnce(t *testing.T) {
+ file := newDataFile("optimal.parquet", 512)
+ plan, err :=
compaction.DefaultConfig().PlanCompaction([]table.FileScanTask{
+ {File: file, Length: file.size / 2},
+ {File: file, Start: file.size / 2, Length: file.size / 2},
+ })
+ require.NoError(t, err)
+ require.Equal(t, 1, plan.TotalInputFiles)
+ require.Equal(t, file.size, plan.TotalInputBytes)
+ require.Equal(t, 1, plan.SkippedFiles)
+ require.Empty(t, plan.Groups)
+}
+
+func TestPlanCompactionRejectsNilFile(t *testing.T) {
+ _, err :=
compaction.DefaultConfig().PlanCompaction([]table.FileScanTask{{}})
+ require.ErrorIs(t, err, table.ErrInvalidOperation)
+}
diff --git a/table/compaction_file_grain_test.go
b/table/compaction_file_grain_test.go
new file mode 100644
index 000000000..5631297d0
--- /dev/null
+++ b/table/compaction_file_grain_test.go
@@ -0,0 +1,173 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/apache/iceberg-go"
+ iceio "github.com/apache/iceberg-go/io"
+ "github.com/apache/iceberg-go/table"
+ "github.com/apache/iceberg-go/table/compaction"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCompactionPreservesSplitFileRows(t *testing.T) {
+ for _, split := range []bool{false, true} {
+ for _, partial := range []bool{false, true} {
+ for _, filtered := range []bool{false, true} {
+
t.Run(fmt.Sprintf("split=%t/partial=%t/filtered=%t", split, partial, filtered),
func(t *testing.T) {
+ tbl := newRewriteTestTable(t)
+ sc, err :=
table.SchemaToArrowSchema(tbl.Schema(), nil, false, false)
+ require.NoError(t, err)
+ var rows []string
+ var want []int64
+ for i := range 12 {
+ rows = append(rows,
fmt.Sprintf(`{"id":%d,"data":"a"}`, i+1))
+ want = append(want, int64(i+1))
+ }
+ path := tbl.Location() +
"/data/split.parquet"
+ writeParquetFileWithProperties(t, path,
sc, "["+strings.Join(rows, ",")+"]", 1, nil)
+ splitSize := "999999999"
+ if split {
+ splitSize = "1"
+ }
+ tx := tbl.NewTransaction()
+ require.NoError(t,
tx.SetProperties(iceberg.Properties{table.ReadSplitTargetSizeKey: splitSize}))
+ require.NoError(t,
tx.AddFiles(t.Context(), []string{path}, nil, false))
+ tbl, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+ scan := tbl.Scan()
+ filter :=
iceberg.GreaterThan(iceberg.Reference("id"), int64(6))
+ if filtered {
+ scan =
tbl.Scan(table.WithRowFilter(filter))
+ }
+ tasks, err :=
scan.PlanFiles(t.Context())
+ require.NoError(t, err)
+ if filtered {
+ // Exercise a residual a remote
planner may leave after scan planning.
+ for i := range tasks {
+ tasks[i].Residual =
filter
+ }
+ require.NotNil(t,
tasks[0].Residual)
+ require.False(t,
tasks[0].Residual.Equals(iceberg.AlwaysTrue{}))
+ }
+ if split {
+ require.Len(t, tasks, 12)
+ } else {
+ require.Len(t, tasks, 1)
+ }
+ size := tasks[0].File.FileSizeBytes()
+ cfg := defaultTestCompactionCfg
+ cfg.TargetFileSizeBytes,
cfg.MinFileSizeBytes, cfg.MaxFileSizeBytes = size+1, size+1, size*2
+ cfg.MinInputFiles = 1
+ var groups []compaction.Group
+ if filtered {
+ plan, planErr :=
cfg.PlanCompaction(tasks)
+ require.NoError(t, planErr)
+ groups = plan.Groups
+ } else {
+ plan, planErr :=
compaction.Analyze(t.Context(), tbl, cfg)
+ require.NoError(t, planErr)
+ groups = plan.Groups
+ }
+ result, err :=
tbl.RewriteDataFiles(t.Context(), toTaskGroups(groups),
table.RewriteDataFilesOptions{
+ PartialProgress: partial,
MaxCommits: 12,
+ })
+ assert.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, result.Table)
+ require.Equal(t, 1,
result.RewrittenGroups)
+ require.ElementsMatch(t, want,
scanIDs(t, result.Table), "a later batch failure must not lose unread source
ranges")
+ })
+ }
+ }
+ }
+}
+
+func TestExecuteCompactionGroupRejectsIncompleteFilesBeforeIO(t *testing.T) {
+ base := newRewriteTestTable(t)
+ builder, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
iceberg.EntryContentData,
+ "missing.parquet", iceberg.ParquetFile, nil, nil, nil, 1, 100)
+ require.NoError(t, err)
+ file := builder.Build()
+ whole := table.FileScanTask{File: file, Length: 100}
+ filtered := whole
+ filtered.Residual = iceberg.EqualTo(iceberg.Reference("id"), int64(1))
+ cases := []struct {
+ name string
+ tasks []table.FileScanTask
+ }{
+ {"nil file", []table.FileScanTask{{}}},
+ {"nonzero start", []table.FileScanTask{{File: file, Start: 1,
Length: 99}}},
+ {"negative start", []table.FileScanTask{{File: file, Start: -1,
Length: 100}}},
+ {"short range", []table.FileScanTask{{File: file, Length: 99}}},
+ {"long range", []table.FileScanTask{{File: file, Length: 101}}},
+ {"negative length", []table.FileScanTask{{File: file, Length:
-1}}},
+ {"duplicate path", []table.FileScanTask{whole, whole}},
+ {"residual filter", []table.FileScanTask{filtered}},
+ {"false residual", []table.FileScanTask{{File: file, Length:
100, Residual: iceberg.AlwaysFalse{}}}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var calls atomic.Int32
+ tbl := table.New(base.Identifier(), base.Metadata(),
base.MetadataLocation(),
+ func(context.Context) (iceio.IO, error) {
+ calls.Add(1)
+
+ return nil, errors.New("unexpected IO")
+ }, nil)
+ result, err :=
table.ExecuteCompactionGroup(t.Context(), tbl, table.CompactionTaskGroup{Tasks:
tc.tasks})
+ require.ErrorIs(t, err, table.ErrInvalidOperation)
+ require.Zero(t, calls.Load(), "invalid groups must fail
before opening input or output files")
+ require.Empty(t, result.NewDataFiles)
+ })
+ }
+}
+
+func TestExecuteCompactionGroupAcceptsWholeFileRanges(t *testing.T) {
+ base := newRewriteTestTable(t)
+ builder, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
iceberg.EntryContentData,
+ "missing.parquet", iceberg.ParquetFile, nil, nil, nil, 1, 100)
+ require.NoError(t, err)
+ file := builder.Build()
+ for _, length := range []int64{0, 100} {
+ t.Run(strconv.FormatInt(length, 10), func(t *testing.T) {
+ var calls atomic.Int32
+ ioErr := errors.New("input reached")
+ tbl := table.New(base.Identifier(), base.Metadata(),
base.MetadataLocation(),
+ func(context.Context) (iceio.IO, error) {
+ calls.Add(1)
+
+ return nil, ioErr
+ }, nil)
+ _, err := table.ExecuteCompactionGroup(t.Context(),
tbl, table.CompactionTaskGroup{Tasks: []table.FileScanTask{
+ {File: file, Length: length, Residual:
iceberg.AlwaysTrue{}},
+ }})
+ require.ErrorIs(t, err, ioErr)
+ require.Equal(t, int32(1), calls.Load())
+ })
+ }
+}
diff --git a/table/rewrite_data_files.go b/table/rewrite_data_files.go
index a1a4f7159..d198b4bea 100644
--- a/table/rewrite_data_files.go
+++ b/table/rewrite_data_files.go
@@ -418,6 +418,11 @@ func (t *Transaction) RewriteDataFiles(ctx
context.Context, groups []CompactionT
// Empty groups return a zero [CompactionGroupResult] without doing
// any I/O.
//
+// Every task must represent one complete physical data file with no residual
+// row filter. Compaction applies the attached deletes, then replaces each
+// source file in full; partial ranges or residual filters would otherwise
+// silently discard rows when the original task is replaced.
+//
// In-process callers should prefer [Transaction.RewriteDataFiles],
// which drives this and the commit step in one call.
//
@@ -429,6 +434,26 @@ func ExecuteCompactionGroup(ctx context.Context, tbl
*Table, group CompactionTas
return CompactionGroupResult{PartitionKey: group.PartitionKey},
nil
}
+ seenFiles := make(map[string]struct{}, len(group.Tasks))
+ for _, task := range group.Tasks {
+ if task.File == nil {
+ return CompactionGroupResult{}, fmt.Errorf("%w:
compaction task has no data file", ErrInvalidOperation)
+ }
+ path := task.File.FilePath()
+ if _, ok := seenFiles[path]; ok {
+ return CompactionGroupResult{}, fmt.Errorf("%w:
compaction group contains duplicate data file %q", ErrInvalidOperation, path)
+ }
+ seenFiles[path] = struct{}{}
+
+ fileSize := task.File.FileSizeBytes()
+ if task.Start != 0 || task.Length < 0 || (task.Length > 0 &&
task.Length != fileSize) {
+ return CompactionGroupResult{}, fmt.Errorf("%w:
compaction task for %q must cover the whole data file", ErrInvalidOperation,
path)
+ }
+ if task.Residual != nil &&
!task.Residual.Equals(iceberg.AlwaysTrue{}) {
+ return CompactionGroupResult{}, fmt.Errorf("%w:
compaction task for %q has a residual row filter", ErrInvalidOperation, path)
+ }
+ }
+
cfg := compactionGroupConfig{}
for _, opt := range opts {
opt(&cfg)
diff --git a/website/src/api.md b/website/src/api.md
index 85504ee5a..bf854e5a2 100644
--- a/website/src/api.md
+++ b/website/src/api.md
@@ -520,6 +520,8 @@ fmt.Printf("rewrote %d files into %d (%d -> %d bytes)\n",
The `table/compaction` subpackage provides bin-packing planning. The `iceberg
compact analyze` and `compact run` CLI commands wrap the same machinery - see
[CLI](./cli.md).
+Compaction tasks represent complete data files, not independent scan splits.
The planner collapses split tasks for the same file into one whole-file task,
rejecting inconsistent delete or lineage metadata and dropping residual filters
because they select files but do not filter rows during compaction. Direct
calls to `ExecuteCompactionGroup` reject partial ranges and residual row
filters. Attached delete files are still applied when rewriting each source
file.
+
### Expiring snapshots
```go