Repository: kopia/kopia
Pull request #1224 (2021-08-01 23:17:42)

build(deps): bump github.com/aws/aws-sdk-go from 1.39.4 to 1.40.12
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.39.4 to 1.40.12.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/aws/aws-sdk-go/releases">github.com/aws/aws-sdk-go's releases</a>.</em></p>
<blockquote>
<h1>Release v1.40.12 (2021-07-30)</h1>
<h3>Service Client Updates</h3>
<ul>
<li><code>service/appsync</code>: Updates service API and documentation</li>
<li><code>service/elasticloadbalancingv2</code>: Updates service documentation</li>
<li><code>service/sagemaker</code>: Updates service API, documentation, and waiters
<ul>
<li>API changes with respect to Lambda steps in model building pipelines. Adds several waiters to async Sagemaker Image APIs. Add more instance types to AppInstanceType field</li>
</ul>
</li>
<li><code>service/secretsmanager</code>: Updates service API and documentation
<ul>
<li>Add support for KmsKeyIds in the ListSecretVersionIds API response</li>
</ul>
</li>
</ul>
<h1>Release v1.40.11 (2021-07-29)</h1>
<h3>Service Client Updates</h3>
<ul>
<li><code>service/chime</code>: Updates service API and documentation
<ul>
<li>Adds support for live transcription of meetings with Amazon Transcribe and Amazon Transcribe Medical.  The new APIs, StartMeetingTranscription and StopMeetingTranscription, control the generation of user-attributed transcriptions sent to meeting clients via Amazon Chime SDK data messages.</li>
</ul>
</li>
<li><code>service/ec2</code>: Updates service API and documentation
<ul>
<li>This release adds support for G4ad xlarge and 2xlarge instances powered by AMD Radeon Pro V520 GPUs and AMD 2nd Generation EPYC processors</li>
</ul>
</li>
<li><code>service/iot</code>: Updates service API and documentation
<ul>
<li>Increase maximum credential duration of role alias to 12 hours.</li>
</ul>
</li>
<li><code>service/iotsitewise</code>: Updates service API and documentation</li>
<li><code>service/savingsplans</code>: Updates service API</li>
</ul>
<h1>Release v1.40.10 (2021-07-28)</h1>
<h3>Service Client Updates</h3>
<ul>
<li><code>service/cloudformation</code>: Updates service API and documentation
<ul>
<li>SDK update to support Importing existing Stacks to new/existing Self Managed StackSet - Stack Import feature.</li>
</ul>
</li>
<li><code>service/sso-admin</code>: Updates service documentation</li>
</ul>
<h1>Release v1.40.9 (2021-07-27)</h1>
<h3>Service Client Updates</h3>
<ul>
<li><code>service/batch</code>: Updates service API and documentation
<ul>
<li>Add support for ListJob filters</li>
</ul>
</li>
<li><code>service/iotanalytics</code>: Updates service API and documentation</li>
<li><code>service/iotwireless</code>: Updates service API and documentation</li>
<li><code>service/models.lex.v2</code>: Updates service waiters</li>
<li><code>service/quicksight</code>: Updates service API and documentation
<ul>
<li>Add support to use row-level security with tags when embedding dashboards for users not provisioned in QuickSight</li>
</ul>
</li>
<li><code>service/redshift-data</code>: Updates service API and documentation</li>
<li><code>service/route53</code>: Updates service API and documentation
<ul>
<li>This release adds support for the RECOVERY_CONTROL health check type to be used in conjunction with Route53 Application Recovery Controller.</li>
</ul>
</li>
<li><code>service/route53-recovery-cluster</code>: Adds new service</li>
<li><code>service/route53-recovery-control-config</code>: Adds new service</li>
<li><code>service/route53-recovery-readiness</code>: Adds new service</li>
<li><code>service/shield</code>: Updates service documentation</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/aws/aws-sdk-go/blob/main/CHANGELOG.md">github.com/aws/aws-sdk-go's changelog</a>.</em></p>
<blockquote>
<h1>Release v1.40.12 (2021-07-30)</h1>
<h3>Service Client Updates</h3>
<ul>
<li><code>service/appsync</code>: Updates service API and documentation</li>
<li><code>service/elasticloadbalancingv2</code>: Updates service documentation</li>
<li><code>service/sagemaker</code>: Updates service API, documentation, and waiters
<ul>
<li>API changes with respect to Lambda steps in model building pipelines. Adds several waiters to async Sagemaker Image APIs. Add more instance types to AppInstanceType field</li>
</ul>
</li>
<li><code>service/secretsmanager</code>: Updates service API and documentation
<ul>
<li>Add support for KmsKeyIds in the ListSecretVersionIds API response</li>
</ul>
</li>
</ul>
<h1>Release v1.40.11 (2021-07-29)</h1>
<h3>Service Client Updates</h3>
<ul>
<li><code>service/chime</code>: Updates service API and documentation
<ul>
<li>Adds support for live transcription of meetings with Amazon Transcribe and Amazon Transcribe Medical.  The new APIs, StartMeetingTranscription and StopMeetingTranscription, control the generation of user-attributed transcriptions sent to meeting clients via Amazon Chime SDK data messages.</li>
</ul>
</li>
<li><code>service/ec2</code>: Updates service API and documentation
<ul>
<li>This release adds support for G4ad xlarge and 2xlarge instances powered by AMD Radeon Pro V520 GPUs and AMD 2nd Generation EPYC processors</li>
</ul>
</li>
<li><code>service/iot</code>: Updates service API and documentation
<ul>
<li>Increase maximum credential duration of role alias to 12 hours.</li>
</ul>
</li>
<li><code>service/iotsitewise</code>: Updates service API and documentation</li>
<li><code>service/savingsplans</code>: Updates service API</li>
</ul>
<h1>Release v1.40.10 (2021-07-28)</h1>
<h3>Service Client Updates</h3>
<ul>
<li><code>service/cloudformation</code>: Updates service API and documentation
<ul>
<li>SDK update to support Importing existing Stacks to new/existing Self Managed StackSet - Stack Import feature.</li>
</ul>
</li>
<li><code>service/sso-admin</code>: Updates service documentation</li>
</ul>
<h1>Release v1.40.9 (2021-07-27)</h1>
<h3>Service Client Updates</h3>
<ul>
<li><code>service/batch</code>: Updates service API and documentation
<ul>
<li>Add support for ListJob filters</li>
</ul>
</li>
<li><code>service/iotanalytics</code>: Updates service API and documentation</li>
<li><code>service/iotwireless</code>: Updates service API and documentation</li>
<li><code>service/models.lex.v2</code>: Updates service waiters</li>
<li><code>service/quicksight</code>: Updates service API and documentation
<ul>
<li>Add support to use row-level security with tags when embedding dashboards for users not provisioned in QuickSight</li>
</ul>
</li>
<li><code>service/redshift-data</code>: Updates service API and documentation</li>
<li><code>service/route53</code>: Updates service API and documentation
<ul>
<li>This release adds support for the RECOVERY_CONTROL health check type to be used in conjunction with Route53 Application Recovery Controller.</li>
</ul>
</li>
<li><code>service/route53-recovery-cluster</code>: Adds new service</li>
<li><code>service/route53-recovery-control-config</code>: Adds new service</li>
<li><code>service/route53-recovery-readiness</code>: Adds new service</li>
<li><code>service/shield</code>: Updates service documentation</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/aws/aws-sdk-go/commit/ee7b112de35c14c3dfe525bcade2718347483dd2"><code>ee7b112</code></a> Release v1.40.12 (2021-07-30) (<a href="https://github-redirect.dependabot.com/aws/aws-sdk-go/issues/4036">#4036</a>)</li>
<li><a href="https://github.com/aws/aws-sdk-go/commit/c48ce71c695c2744de0867c1e914368817697c0b"><code>c48ce71</code></a> Release v1.40.11 (2021-07-29) (<a href="https://github-redirect.dependabot.com/aws/aws-sdk-go/issues/4035">#4035</a>)</li>
<li><a href="https://github.com/aws/aws-sdk-go/commit/abb47fb1b84a728ec555478ec92d2c1d6a84e825"><code>abb47fb</code></a> service/s3: Update unit test to clarify implemented behavior (<a href="https://github-redirect.dependabot.com/aws/aws-sdk-go/issues/4033">#4033</a>)</li>
<li><a href="https://github.com/aws/aws-sdk-go/commit/a4a27c7d858f3d7b75534af65480997189fddee0"><code>a4a27c7</code></a> Release v1.40.10 (2021-07-28) (<a href="https://github-redirect.dependabot.com/aws/aws-sdk-go/issues/4032">#4032</a>)</li>
<li><a href="https://github.com/aws/aws-sdk-go/commit/5f4eb6a16475469c56d3691acd31c4e9ea0fa869"><code>5f4eb6a</code></a> Release v1.40.9 (2021-07-27) (<a href="https://github-redirect.dependabot.com/aws/aws-sdk-go/issues/4031">#4031</a>)</li>
<li><a href="https://github.com/aws/aws-sdk-go/commit/9e261711fdee3b565ef0cb4f94a28610decaa0e0"><code>9e26171</code></a> Release v1.40.8 (2021-07-26) (<a href="https://github-redirect.dependabot.com/aws/aws-sdk-go/issues/4026">#4026</a>)</li>
<li><a href="https://github.com/aws/aws-sdk-go/commit/4c222f528c29c81077f2faa177d86708f3eebf89"><code>4c222f5</code></a> Release v1.40.7 (2021-07-23) (<a href="https://github-redirect.dependabot.com/aws/aws-sdk-go/issues/4024">#4024</a>)</li>
<li><a href="https://github.com/aws/aws-sdk-go/commit/43d05f640c5325a177121cfc102ae72265f8ced8"><code>43d05f6</code></a> Release v1.40.6 (2021-07-22) (<a href="https://github-redirect.dependabot.com/aws/aws-sdk-go/issues/4019">#4019</a>)</li>
<li><a href="https://github.com/aws/aws-sdk-go/commit/287bf610b41fa8b86e0db1904a103db7ec3ec37f"><code>287bf61</code></a> Release v1.40.5 (2021-07-21) (<a href="https://github-redirect.dependabot.com/aws/aws-sdk-go/issues/4017">#4017</a>)</li>
<li><a href="https://github.com/aws/aws-sdk-go/commit/b74ebc060600028867530199ee17b993d8dbd98a"><code>b74ebc0</code></a> Release v1.40.4 (2021-07-20) (<a href="https://github-redirect.dependabot.com/aws/aws-sdk-go/issues/4015">#4015</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/aws/aws-sdk-go/compare/v1.39.4...v1.40.12">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/aws/aws-sdk-go&package-manager=go_modules&previous-version=1.39.4&new-version=1.40.12)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>

codecov[bot]: # [Codecov](https://codecov.io/gh/kopia/kopia/pull/1224?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
> Merging [#1224](https://codecov.io/gh/kopia/kopia/pull/1224?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) (01589ce) into [master](https://codecov.io/gh/kopia/kopia/commit/d77e771df7a121456f5a3639795a93be8f0bf633?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) (d77e771) will **decrease** coverage by `0.00%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/kopia/kopia/pull/1224/graphs/tree.svg?width=650&height=150&src=pr&token=CRK4RMRFSH&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)](https://codecov.io/gh/kopia/kopia/pull/1224?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)

```diff
@@            Coverage Diff             @@
##           master    #1224      +/-   ##
==========================================
- Coverage   68.07%   68.06%   -0.01%     
==========================================
  Files         362      362              
  Lines       21365    21365              
==========================================
- Hits        14544    14542       -2     
- Misses       5255     5256       +1     
- Partials     1566     1567       +1     
```


| [Impacted Files](https://codecov.io/gh/kopia/kopia/pull/1224?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | Coverage Δ | |
|---|---|---|
| [repo/content/encrypted\_blob\_mgr.go](https://codecov.io/gh/kopia/kopia/pull/1224/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-cmVwby9jb250ZW50L2VuY3J5cHRlZF9ibG9iX21nci5nbw==) | `63.15% <0.00%> (-10.53%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/kopia/kopia/pull/1224?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/kopia/kopia/pull/1224?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia). Last update [d77e771...01589ce](https://codecov.io/gh/kopia/kopia/pull/1224?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).


Patch:
diff --git a/Makefile b/Makefile
index 7a49f3b6..3bdee605 100644
--- a/Makefile
+++ b/Makefile
@@ -13,7 +13,7 @@ rwildcard=$(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst
 go_source_dirs=cli fs internal repo snapshot
 all_go_sources=$(foreach d,$(go_source_dirs),$(call rwildcard,$d/,*.go)) $(wildcard *.go)
 
-all: test lint vet integration-tests
+all: test lint vet integration-tests integration-tests-index-v2
 
 include tools/tools.mk
 
@@ -238,7 +238,7 @@ integration-tests: build-integration-test-binary $(gotestsum) $(TESTING_ACTION_E
 	 -$(gotestsum) tool slowest --jsonfile .tmp.integration-tests.json  --threshold 1000ms
 
 integration-tests-index-v2:
-	KOPIA_CREATE_INDEX_VERSION=2 KOPIA_RUN_ALL_INTEGRATION_TESTS=true $(MAKE) integration-tests
+	KOPIA_CREATE_INDEX_VERSION=2 KOPIA_ENABLE_INDEX_EPOCHS=true KOPIA_RUN_ALL_INTEGRATION_TESTS=true $(MAKE) integration-tests
 
 endurance-tests: export KOPIA_EXE ?= $(KOPIA_INTEGRATION_EXE)
 endurance-tests: export KOPIA_LOGS_DIR=$(CURDIR)/.logs
diff --git a/cli/auto_upgrade.go b/cli/auto_upgrade.go
index 9d31b088..f84f9d5f 100644
--- a/cli/auto_upgrade.go
+++ b/cli/auto_upgrade.go
@@ -39,6 +39,13 @@ func setDefaultMaintenanceParameters(ctx context.Context, rep repo.RepositoryWri
 	p := maintenance.DefaultParams()
 	p.Owner = rep.ClientOptions().UsernameAtHost()
 
+	if dw, ok := rep.(repo.DirectRepositoryWriter); ok {
+		if _, ok := dw.ContentReader().EpochManager(); ok {
+			// disable quick maintenance cycle
+			p.QuickCycle.Enabled = false
+		}
+	}
+
 	if err := maintenance.SetParams(ctx, rep, &p); err != nil {
 		return errors.Wrap(err, "unable to set maintenance params")
 	}
diff --git a/cli/command_blob_list.go b/cli/command_blob_list.go
index 27c3940d..fea6ffca 100644
--- a/cli/command_blob_list.go
+++ b/cli/command_blob_list.go
@@ -2,15 +2,20 @@
 
 import (
 	"context"
+	"strings"
 
+	"github.com/kopia/kopia/internal/epoch"
 	"github.com/kopia/kopia/repo"
 	"github.com/kopia/kopia/repo/blob"
+	"github.com/kopia/kopia/repo/content"
 )
 
 type commandBlobList struct {
-	blobListPrefix  string
-	blobListMinSize int64
-	blobListMaxSize int64
+	blobListPrefix        string
+	blobListPrefixExclude []string
+	blobListMinSize       int64
+	blobListMaxSize       int64
+	dataOnly              bool
 
 	jo  jsonOutput
 	out textOutput
@@ -19,8 +24,10 @@ type commandBlobList struct {
 func (c *commandBlobList) setup(svc appServices, parent commandParent) {
 	cmd := parent.Command("list", "List BLOBs").Alias("ls")
 	cmd.Flag("prefix", "Blob ID prefix").StringVar(&c.blobListPrefix)
+	cmd.Flag("exclude-prefix", "Blob ID prefixes to exclude").StringsVar(&c.blobListPrefixExclude)
 	cmd.Flag("min-size", "Minimum size").Int64Var(&c.blobListMinSize)
 	cmd.Flag("max-size", "Maximum size").Int64Var(&c.blobListMaxSize)
+	cmd.Flag("data-only", "Only list data blobs").BoolVar(&c.dataOnly)
 	c.jo.setup(svc, cmd)
 	c.out.setup(svc)
 	cmd.Action(svc.directRepositoryReadAction(c.run))
@@ -34,11 +41,7 @@ func (c *commandBlobList) run(ctx context.Context, rep repo.DirectRepository) er
 
 	// nolint:wrapcheck
 	return rep.BlobReader().ListBlobs(ctx, blob.ID(c.blobListPrefix), func(b blob.Metadata) error {
-		if c.blobListMaxSize != 0 && b.Length > c.blobListMaxSize {
-			return nil
-		}
-
-		if c.blobListMinSize != 0 && b.Length < c.blobListMinSize {
+		if !c.shouldInclude(b) {
 			return nil
 		}
 
@@ -51,3 +54,39 @@ func (c *commandBlobList) run(ctx context.Context, rep repo.DirectRepository) er
 		return nil
 	})
 }
+
+func (c *commandBlobList) shouldInclude(b blob.Metadata) bool {
+	if c.dataOnly {
+		if strings.HasPrefix(string(b.BlobID), content.IndexBlobPrefix) {
+			return false
+		}
+
+		if strings.HasPrefix(string(b.BlobID), epoch.EpochManagerIndexUberPrefix) {
+			return false
+		}
+
+		if strings.HasPrefix(string(b.BlobID), content.TextLogBlobPrefix) {
+			return false
+		}
+
+		if strings.HasPrefix(string(b.BlobID), "kopia.") {
+			return false
+		}
+	}
+
+	if c.blobListMaxSize != 0 && b.Length > c.blobListMaxSize {
+		return false
+	}
+
+	if c.blobListMinSize != 0 && b.Length < c.blobListMinSize {
+		return false
+	}
+
+	for _, ex := range c.blobListPrefixExclude {
+		if strings.HasPrefix(string(b.BlobID), ex) {
+			return false
+		}
+	}
+
+	return true
+}
diff --git a/cli/command_logs_session.go b/cli/command_logs_session.go
index b2917846..9667f782 100644
--- a/cli/command_logs_session.go
+++ b/cli/command_logs_session.go
@@ -12,6 +12,7 @@
 
 	"github.com/kopia/kopia/internal/clock"
 	"github.com/kopia/kopia/repo/blob"
+	"github.com/kopia/kopia/repo/content"
 )
 
 type logSessionInfo struct {
@@ -69,7 +70,7 @@ func getLogSessions(ctx context.Context, st blob.Reader) ([]*logSessionInfo, err
 
 	var allSessions []*logSessionInfo
 
-	if err := st.ListBlobs(ctx, "_log_", func(bm blob.Metadata) error {
+	if err := st.ListBlobs(ctx, content.TextLogBlobPrefix, func(bm blob.Metadata) error {
 		parts := strings.Split(string(bm.BlobID), "_")
 
 		// nolint:gomnd
diff --git a/cli/command_maintenance_run.go b/cli/command_maintenance_run.go
index f4b2a1d3..60a20509 100644
--- a/cli/command_maintenance_run.go
+++ b/cli/command_maintenance_run.go
@@ -25,7 +25,9 @@ func (c *commandMaintenanceRun) setup(svc appServices, parent commandParent) {
 
 func (c *commandMaintenanceRun) run(ctx context.Context, rep repo.DirectRepositoryWriter) error {
 	mode := maintenance.ModeQuick
-	if c.maintenanceRunFull {
+	_, supportsEpochManager := rep.ContentManager().EpochManager()
+
+	if c.maintenanceRunFull || supportsEpochManager {
 		mode = maintenance.ModeFull
 	}
 
diff --git a/cli/command_repository_create.go b/cli/command_repository_create.go
index 6a1146b5..62fed38a 100644
--- a/cli/command_repository_create.go
+++ b/cli/command_repository_create.go
@@ -46,7 +46,7 @@ func (c *commandRepositoryCreate) setup(svc advancedAppServices, parent commandP
 	cmd.Flag("create-only", "Create repository, but don't connect to it.").Short('c').BoolVar(&c.createOnly)
 	cmd.Flag("enable-password-change", "Enable password change").Hidden().Default("true").BoolVar(&c.enablePasswordChange)
 	cmd.Flag("index-version", "Force particular index version").Hidden().Envar("KOPIA_CREATE_INDEX_VERSION").IntVar(&c.createIndexVersion)
-	cmd.Flag("enable-index-epochs", "Enable index epochs").Hidden().BoolVar(&c.createIndexEpochs)
+	cmd.Flag("enable-index-epochs", "Enable index epochs").Hidden().Envar("KOPIA_ENABLE_INDEX_EPOCHS").BoolVar(&c.createIndexEpochs)
 
 	c.co.setup(cmd)
 	c.svc = svc
diff --git a/go.mod b/go.mod
index 2dbdf23d..f5858e14 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,7 @@ require (
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 // indirect
 	github.com/alecthomas/kingpin v0.0.0-20200323085623-b6657d9477a6 // this is pulling master, which is newer than v2
 	github.com/alecthomas/units v0.0.0-20210208195552-ff826a37aa15
-	github.com/aws/aws-sdk-go v1.39.4
+	github.com/aws/aws-sdk-go v1.40.12
 	github.com/chmduquesne/rollinghash v4.0.0+incompatible
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0
diff --git a/go.sum b/go.sum
index 3ae480a0..f2bf9c57 100644
--- a/go.sum
+++ b/go.sum
@@ -126,8 +126,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
 github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
-github.com/aws/aws-sdk-go v1.39.4 h1:nXBChUaG5cinrl3yg4/rUyssOOLH/ohk4S9K03kJirE=
-github.com/aws/aws-sdk-go v1.39.4/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.40.12 h1:66+IAWhl+aaZCW1+ndS/GNfAxy8tJca2cMoIF2O325I=
+github.com/aws/aws-sdk-go v1.40.12/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
diff --git a/internal/epoch/epoch_manager.go b/internal/epoch/epoch_manager.go
index 6e2fcdfd..df2c2477 100644
--- a/internal/epoch/epoch_manager.go
+++ b/internal/epoch/epoch_manager.go
@@ -75,7 +75,7 @@ func (p *Parameters) Validate() error {
 		return errors.Errorf("invalid epoch checkpoint frequency")
 	}
 
-	if p.CleanupSafetyMargin*3 < p.EpochRefreshFrequency {
+	if p.CleanupSafetyMargin < p.EpochRefreshFrequency*3 {
 		return errors.Errorf("invalid cleanup safety margin, must be at least 3x epoch refresh frequency")
 	}
 
@@ -111,6 +111,7 @@ type CurrentSnapshot struct {
 	LongestRangeCheckpointSets []*RangeMetadata        `json:"longestRangeCheckpointSets"`
 	SingleEpochCompactionSets  map[int][]blob.Metadata `json:"singleEpochCompactionSets"`
 	EpochStartTime             map[int]time.Time       `json:"epochStartTimes"`
+	DeletionWatermark          time.Time               `json:"deletionWatermark"`
 	ValidUntil                 time.Time               `json:"validUntil"` // time after which the contents of this struct are no longer valid
 }
 
@@ -143,10 +144,13 @@ type Manager struct {
 
 // Index blob prefixes.
 const (
-	EpochMarkerIndexBlobPrefix      blob.ID = "xe"
-	UncompactedIndexBlobPrefix      blob.ID = "xn"
-	SingleEpochCompactionBlobPrefix blob.ID = "xs"
-	RangeCheckpointIndexBlobPrefix  blob.ID = "xr"
+	EpochManagerIndexUberPrefix = "x"
+
+	EpochMarkerIndexBlobPrefix      blob.ID = EpochManagerIndexUberPrefix + "e"
+	UncompactedIndexBlobPrefix      blob.ID = EpochManagerIndexUberPrefix + "n"
+	SingleEpochCompactionBlobPrefix blob.ID = EpochManagerIndexUberPrefix + "s"
+	RangeCheckpointIndexBlobPrefix  blob.ID = EpochManagerIndexUberPrefix + "r"
+	DeletionWatermarkBlobPrefix     blob.ID = EpochManagerIndexUberPrefix + "w"
 )
 
 // FirstEpoch is the number of the first epoch in a repository.
@@ -169,6 +173,29 @@ func (e *Manager) Current(ctx context.Context) (CurrentSnapshot, error) {
 	return e.committedState(ctx)
 }
 
+// AdvanceDeletionWatermark moves the deletion watermark time to a given timestamp
+// this causes all deleted content entries before given time to be treated as non-existent.
+func (e *Manager) AdvanceDeletionWatermark(ctx context.Context, ts time.Time) error {
+	cs, err := e.committedState(ctx)
+	if err != nil {
+		return err
+	}
+
+	if ts.Before(cs.DeletionWatermark) {
+		return errors.Errorf("deletion watermark time cannot move backwards")
+	}
+
+	blobID := blob.ID(fmt.Sprintf("%v%v", string(DeletionWatermarkBlobPrefix), ts.Unix()))
+
+	if err := e.st.PutBlob(ctx, blobID, gather.FromSlice([]byte("deletion-watermark"))); err != nil {
+		return errors.Wrap(err, "error writing deletion watermark")
+	}
+
+	e.Invalidate()
+
+	return nil
+}
+
 // ForceAdvanceEpoch advances current epoch unconditionally.
 func (e *Manager) ForceAdvanceEpoch(ctx context.Context) error {
 	cs, err := e.committedState(ctx)
@@ -228,51 +255,88 @@ func (e *Manager) cleanupInternal(ctx context.Context, cs CurrentSnapshot) error
 	// may have not observed them yet.
 	maxReplacementTime := maxTime.Add(-e.Params.CleanupSafetyMargin)
 
-	// delete epoch markers for epoch < current-1
 	eg.Go(func() error {
-		var toDelete []blob.ID
+		return e.cleanupEpochMarkers(ctx, cs)
+	})
 
-		if err := e.st.ListBlobs(ctx, EpochMarkerIndexBlobPrefix, func(bm blob.Metadata) error {
-			if n, ok := epochNumberFromBlobID(bm.BlobID); ok {
-				if n < cs.WriteEpoch-1 {
-					toDelete = append(toDelete, bm.BlobID)
-				}
+	eg.Go(func() error {
+		return e.cleanupUncompactedIndexes(ctx, cs, maxReplacementTime)
+	})
+
+	eg.Go(func() error {
+		return e.cleanupWatermarks(ctx, cs, maxReplacementTime)
+	})
+
+	return errors.Wrap(eg.Wait(), "error cleaning up index blobs")
+}
+
+func (e *Manager) cleanupEpochMarkers(ctx context.Context, cs CurrentSnapshot) error {
+	// delete epoch markers for epoch < current-1
+	var toDelete []blob.ID
+
+	if err := e.st.ListBlobs(ctx, EpochMarkerIndexBlobPrefix, func(bm blob.Metadata) error {
+		if n, ok := epochNumberFromBlobID(bm.BlobID); ok {
+			if n < cs.WriteEpoch-1 {
+				toDelete = append(toDelete, bm.BlobID)
 			}
+		}
+
+		return nil
+	}); err != nil {
+		return errors.Wrap(err, "error listing epoch markers")
+	}
 
+	return errors.Wrap(blob.DeleteMultiple(ctx, e.st, toDelete, e.Params.DeleteParallelism), "error deleting index blob marker")
+}
+
+func (e *Manager) cleanupWatermarks(ctx context.Context, cs CurrentSnapshot, maxReplacementTime time.Time) error {
+	var toDelete []blob.ID
+
+	if err := e.st.ListBlobs(ctx, DeletionWatermarkBlobPrefix, func(bm blob.Metadata) error {
+		ts, ok := deletionWatermarkFromBlobID(bm.BlobID)
+		if !ok {
 			return nil
-		}); err != nil {
-			return errors.Wrap(err, "error listing epoch markers")
 		}
 
-		return errors.Wrap(blob.DeleteMultiple(ctx, e.st, toDelete, e.Params.DeleteParallelism), "error deleting index blob marker")
-	})
+		if ts.Equal(cs.DeletionWatermark) {
+			return nil
+		}
+
+		if bm.Timestamp.Before(maxReplacementTime) {
+			toDelete = append(toDelete, bm.BlobID)
+		}
+
+		return nil
+	}); err != nil {
+		return errors.Wrap(err, "error listing watermark blobs")
+	}
 
+	return errors.Wrap(blob.DeleteMultiple(ctx, e.st, toDelete, e.Params.DeleteParallelism), "error deleting watermark blobs")
+}
+
+func (e *Manager) cleanupUncompactedIndexes(ctx context.Context, cs CurrentSnapshot, maxReplacementTime time.Time) error {
 	// delete uncompacted indexes for epochs that already have single-epoch compaction
 	// that was written sufficiently long ago.
-	eg.Go(func() error {
-		blobs, err := blob.ListAllBlobs(ctx, e.st, UncompactedIndexBlobPrefix)
-		if err != nil {
-			return errors.Wrap(err, "error listing uncompacted blobs")
-		}
+	blobs, err := blob.ListAllBlobs(ctx, e.st, UncompactedIndexBlobPrefix)
+	if err != nil {
+		return errors.Wrap(err, "error listing uncompacted blobs")
+	}
 
-		var toDelete []blob.ID
+	var toDelete []blob.ID
 
-		for _, bm := range blobs {
-			if epoch, ok := epochNumberFromBlobID(bm.BlobID); ok {
-				if blobSetWrittenEarlyEnough(cs.SingleEpochCompactionSets[epoch], maxReplacementTime) {
-					toDelete = append(toDelete, bm.BlobID)
-				}
+	for _, bm := range blobs {
+		if epoch, ok := epochNumberFromBlobID(bm.BlobID); ok {
+			if blobSetWrittenEarlyEnough(cs.SingleEpochCompactionSets[epoch], maxReplacementTime) {
+				toDelete = append(toDelete, bm.BlobID)
 			}
 		}
+	}
 
-		if err := blob.DeleteMultiple(ctx, e.st, toDelete, e.Params.DeleteParallelism); err != nil {
-			return errors.Wrap(err, "unable to delete uncompacted blobs")
-		}
-
-		return nil
-	})
+	if err := blob.DeleteMultiple(ctx, e.st, toDelete, e.Params.DeleteParallelism); err != nil {
+		return errors.Wrap(err, "unable to delete uncompacted blobs")
+	}
 
-	return errors.Wrap(eg.Wait(), "error cleaning up index blobs")
+	return nil
 }
 
 func blobSetWrittenEarlyEnough(replacementSet []blob.Metadata, maxReplacementTime time.Time) bool {
@@ -332,6 +396,27 @@ func (e *Manager) loadWriteEpoch(ctx context.Context, cs *CurrentSnapshot) error
 	return nil
 }
 
+func (e *Manager) loadDeletionWatermark(ctx context.Context, cs *CurrentSnapshot) error {
+	blobs, err := blob.ListAllBlobs(ctx, e.st, DeletionWatermarkBlobPrefix)
+	if err != nil {
+		return errors.Wrap(err, "error loading write epoch")
+	}
+
+	for _, b := range blobs {
+		t, ok := deletionWatermarkFromBlobID(b.BlobID)
+		if !ok {
+			e.log.Debugf("ignoring malformed deletion watermark: %v", b.BlobID)
+			continue
+		}
+
+		if t.After(cs.DeletionWatermark) {
+			cs.DeletionWatermark = t
+		}
+	}
+
+	return nil
+}
+
 func (e *Manager) loadRangeCheckpoints(ctx context.Context, cs *CurrentSnapshot) error {
 	blobs, err := blob.ListAllBlobs(ctx, e.st, RangeCheckpointIndexBlobPrefix)
 	if err != nil {
@@ -473,6 +558,9 @@ func (e *Manager) refreshAttemptLocked(ctx context.Context) error {
 	eg.Go(func() error {
 		return e.loadWriteEpoch(ctx1, &cs)
 	})
+	eg.Go(func() error {
+		return e.loadDeletionWatermark(ctx1, &cs)
+	})
 	eg.Go(func() error {
 		return e.loadSingleEpochCompactions(ctx1, &cs)
 	})
@@ -548,11 +636,11 @@ func (e *Manager) committedState(ctx context.Context) (CurrentSnapshot, error) {
 }
 
 // GetCompleteIndexSet returns the set of blobs forming a complete index set up to the provided epoch number.
-func (e *Manager) GetCompleteIndexSet(ctx context.Context, maxEpoch int) ([]blob.Metadata, error) {
+func (e *Manager) GetCompleteIndexSet(ctx context.Context, maxEpoch int) ([]blob.Metadata, time.Time, error) {
 	for {
 		cs, err := e.committedState(ctx)
 		if err != nil {
-			return nil, err
+			return nil, time.Time{}, err
 		}
 
 		if maxEpoch == LatestEpoch {
@@ -561,8 +649,8 @@ func (e *Manager) GetCompleteIndexSet(ctx context.Context, maxEpoch int) ([]blob
 
 		result, err := e.getCompleteIndexSetForCommittedState(ctx, cs, 0, maxEpoch)
 		if e.timeFunc().Before(cs.ValidUntil) {
-			e.log.Debugf("Complete Index Set for [%v..%v]: %v", 0, maxEpoch, blob.IDsFromMetadata(result))
-			return result, err
+			e.log.Debugf("Complete Index Set for [%v..%v]: %v, deletion watermark %v", 0, maxEpoch, blob.IDsFromMetadata(result), cs.DeletionWatermark)
+			return result, cs.DeletionWatermark, err
 		}
 
 		// We need to retry if local process took too long (e.g. because the machine went
diff --git a/internal/epoch/epoch_utils.go b/internal/epoch/epoch_utils.go
index b41bb1a3..ea463f1c 100644
--- a/internal/epoch/epoch_utils.go
+++ b/internal/epoch/epoch_utils.go
@@ -94,3 +94,14 @@ func groupByEpochRanges(bms []blob.Metadata) map[int]map[int][]blob.Metadata {
 
 	return result
 }
+
+func deletionWatermarkFromBlobID(blobID blob.ID) (time.Time, bool) {
+	str := strings.TrimPrefix(string(blobID), string(DeletionWatermarkBlobPrefix))
+
+	unixSeconds, err := strconv.ParseInt(str, 10, 64) // nolint:gomnd
+	if err != nil {
+		return time.Time{}, false
+	}
+
+	return time.Unix(unixSeconds, 0), true
+}
diff --git a/repo/content/committed_content_index.go b/repo/content/committed_content_index.go
index b77100ec..8d0735f5 100644
--- a/repo/content/committed_content_index.go
+++ b/repo/content/committed_content_index.go
@@ -6,6 +6,7 @@
 	"path/filepath"
 	"sync"
 	"sync/atomic"
+	"time"
 
 	"github.com/pkg/errors"
 	"golang.org/x/sync/errgroup"
@@ -25,9 +26,10 @@ type committedContentIndex struct {
 	rev   int64
 	cache committedContentIndexCache
 
-	mu     sync.Mutex
-	inUse  map[blob.ID]packIndex
-	merged mergedIndex
+	mu                sync.Mutex
+	deletionWatermark time.Time
+	inUse             map[blob.ID]packIndex
+	merged            mergedIndex
 
 	v1PerContentOverhead uint32
 	indexVersion         int
@@ -55,6 +57,10 @@ func (c *committedContentIndex) getContent(contentID ID) (Info, error) {
 
 	info, err := c.merged.GetInfo(contentID)
 	if info != nil {
+		if c.shouldIgnore(info) {
+			return nil, ErrContentNotFound
+		}
+
 		return info, nil
 	}
 
@@ -65,6 +71,14 @@ func (c *committedContentIndex) getContent(contentID ID) (Info, error) {
 	return nil, err
 }
 
+func (c *committedContentIndex) shouldIgnore(id Info) bool {
+	if !id.GetDeleted() {
+		return false
+	}
+
+	return !id.Timestamp().After(c.deletionWatermark)
+}
+
 func (c *committedContentIndex) addIndexBlob(ctx context.Context, indexBlobID blob.ID, data []byte, use bool) error {
 	// ensure we bump revision number AFTER this function
 	// doing it prematurely might confuse callers of revision() who may cache
@@ -105,7 +119,13 @@ func (c *committedContentIndex) listContents(r IDRange, cb func(i Info) error) e
 	m := append(mergedIndex(nil), c.merged...)
 	c.mu.Unlock()
 
-	return m.Iterate(r, cb)
+	return m.Iterate(r, func(i Info) error {
+		if c.shouldIgnore(i) {
+			return nil
+		}
+
+		return cb(i)
+	})
 }
 
 func (c *committedContentIndex) indexFilesChanged(indexFiles []blob.ID) bool {
@@ -156,10 +176,12 @@ func (c *committedContentIndex) merge(ctx context.Context, indexFiles []blob.ID)
 
 // Uses indexFiles for indexing. An error is returned if the
 // indices cannot be read for any reason.
-func (c *committedContentIndex) use(ctx context.Context, indexFiles []blob.ID) error {
+func (c *committedContentIndex) use(ctx context.Context, indexFiles []blob.ID, ignoreDeletedBefore time.Time) error {
 	c.mu.Lock()
 	defer c.mu.Unlock()
 
+	c.deletionWatermark = ignoreDeletedBefore
+
 	if !c.indexFilesChanged(indexFiles) {
 		return nil
 	}
diff --git a/repo/content/committed_read_manager.go b/repo/content/committed_read_manager.go
index e5d76b60..0b866c04 100644
--- a/repo/content/committed_read_manager.go
+++ b/repo/content/committed_read_manager.go
@@ -44,7 +44,7 @@
 // indexBlobManager is the API of index blob manager as used by content manager.
 type indexBlobManager interface {
 	writeIndexBlobs(ctx context.Context, data [][]byte, sessionID SessionID) ([]blob.Metadata, error)
-	listActiveIndexBlobs(ctx context.Context) ([]IndexBlobInfo, error)
+	listActiveIndexBlobs(ctx context.Context) ([]IndexBlobInfo, time.Time, error)
 	compact(ctx context.Context, opts CompactOptions) error
 	flushCache(ctx context.Context)
 }
@@ -171,7 +171,7 @@ func (sm *SharedManager) loadPackIndexesUnlocked(ctx context.Context) error {
 			nextSleepTime *= 2
 		}
 
-		indexBlobs, err := sm.indexBlobManager.listActiveIndexBlobs(ctx)
+		indexBlobs, ignoreDeletedBefore, err := sm.indexBlobManager.listActiveIndexBlobs(ctx)
 		if err != nil {
 			return errors.Wrap(err, "error listing index blobs")
 		}
@@ -183,7 +183,7 @@ func (sm *SharedManager) loadPackIndexesUnlocked(ctx context.Context) error {
 
 		err = sm.committedContents.fetchIndexBlobs(ctx, indexBlobIDs)
 		if err == nil {
-			err = sm.committedContents.use(ctx, indexBlobIDs)
+			err = sm.committedContents.use(ctx, indexBlobIDs, ignoreDeletedBefore)
 			if err != nil {
 				return err
 			}
@@ -284,8 +284,10 @@ func (sm *SharedManager) IndexBlobs(ctx context.Context, includeInactive bool) (
 		return result, nil
 	}
 
+	blobs, _, err := sm.indexBlobManager.listActiveIndexBlobs(ctx)
+
 	// nolint:wrapcheck
-	return sm.indexBlobManager.listActiveIndexBlobs(ctx)
+	return blobs, err
 }
 
 func newOwnWritesCache(ctx context.Context, st blob.Storage, caching *CachingOptions) (blob.Storage, error) {
diff --git a/repo/content/index_blob_manager_v0.go b/repo/content/index_blob_manager_v0.go
index 3cce67b9..8fe29e1d 100644
--- a/repo/content/index_blob_manager_v0.go
+++ b/repo/content/index_blob_manager_v0.go
@@ -54,7 +54,7 @@ type indexBlobManagerV0 struct {
 	indexShardSize int
 }
 
-func (m *indexBlobManagerV0) listActiveIndexBlobs(ctx context.Context) ([]IndexBlobInfo, error) {
+func (m *indexBlobManagerV0) listActiveIndexBlobs(ctx context.Context) ([]IndexBlobInfo, time.Time, error) {
 	var compactionLogMetadata, storageIndexBlobs []blob.Metadata
 
 	var eg errgroup.Group
@@ -75,7 +75,7 @@ func (m *indexBlobManagerV0) listActiveIndexBlobs(ctx context.Context) ([]IndexB
 	})
 
 	if err := eg.Wait(); err != nil {
-		return nil, errors.Wrap(err, "error listing indexes")
+		return nil, time.Time{}, errors.Wrap(err, "error listing indexes")
 	}
 
 	for i, sib := range storageIndexBlobs {
@@ -91,7 +91,7 @@ func (m *indexBlobManagerV0) listActiveIndexBlobs(ctx context.Context) ([]IndexB
 
 	compactionLogs, err := m.getCompactionLogEntries(ctx, compactionLogMetadata)
 	if err != nil {
-		return nil, errors.Wrap(err, "error reading compaction log")
+		return nil, time.Time{}, errors.Wrap(err, "error reading compaction log")
 	}
 
 	// remove entries from indexMap that have been compacted and replaced by other indexes.
@@ -106,7 +106,7 @@ func (m *indexBlobManagerV0) listActiveIndexBlobs(ctx context.Context) ([]IndexB
 		m.log.Debugf("active-index-blobs[%v] = %v", i, res)
 	}
 
-	return results, nil
+	return results, time.Time{}, nil
 }
 
 func (m *indexBlobManagerV0) flushCache(ctx context.Context) {
@@ -116,7 +116,7 @@ func (m *indexBlobManagerV0) flushCache(ctx context.Context) {
 }
 
 func (m *indexBlobManagerV0) compact(ctx context.Context, opt CompactOptions) error {
-	indexBlobs, err := m.listActiveIndexBlobs(ctx)
+	indexBlobs, _, err := m.listActiveIndexBlobs(ctx)
 	if err != nil {
 		return errors.Wrap(err, "error listing active index blobs")
 	}
diff --git a/repo/content/index_blob_manager_v1.go b/repo/content/index_blob_manager_v1.go
index ad249c40..e9cdd103 100644
--- a/repo/content/index_blob_manager_v1.go
+++ b/repo/content/index_blob_manager_v1.go
@@ -25,10 +25,10 @@ type indexBlobManagerV1 struct {
 	indexShardSize int
 }
 
-func (m *indexBlobManagerV1) listActiveIndexBlobs(ctx context.Context) ([]IndexBlobInfo, error) {
-	active, err := m.epochMgr.GetCompleteIndexSet(ctx, epoch.LatestEpoch)
+func (m *indexBlobManagerV1) listActiveIndexBlobs(ctx context.Context) ([]IndexBlobInfo, time.Time, error) {
+	active, deletionWatermark, err := m.epochMgr.GetCompleteIndexSet(ctx, epoch.LatestEpoch)
 	if err != nil {
-		return nil, errors.Wrap(err, "error getting index set")
+		return nil, time.Time{}, errors.Wrap(err, "error getting index set")
 	}
 
 	var result []IndexBlobInfo
@@ -37,9 +37,9 @@ func (m *indexBlobManagerV1) listActiveIndexBlobs(ctx context.Context) ([]IndexB
 		result = append(result, IndexBlobInfo{Metadata: bm})
 	}
 
-	m.log.Errorf("active indexes %v", blob.IDsFromMetadata(active))
+	m.log.Errorf("active indexes %v deletion watermark %v", blob.IDsFromMetadata(active), deletionWatermark)
 
-	return result, nil
+	return result, deletionWatermark, nil
 }
 
 func (m *indexBlobManagerV1) flushCache(ctx context.Context) {
@@ -49,7 +49,11 @@ func (m *indexBlobManagerV1) flushCache(ctx context.Context) {
 }
 
 func (m *indexBlobManagerV1) compact(ctx context.Context, opt CompactOptions) error {
-	return nil
+	if opt.DropDeletedBefore.IsZero() {
+		return nil
+	}
+
+	return errors.Wrap(m.epochMgr.AdvanceDeletionWatermark(ctx, opt.DropDeletedBefore), "error advancing deletion watermark")
 }
 
 func (m *indexBlobManagerV1) compactEpoch(ctx context.Context, blobIDs []blob.ID, outputPrefix blob.ID) error {
@@ -112,7 +116,7 @@ func (m *indexBlobManagerV1) writeIndexBlobs(ctx context.Context, dataShards [][
 func (sm *SharedManager) PrepareUpgradeToIndexBlobManagerV1(ctx context.Context, params epoch.Parameters) error {
 	sm.indexBlobManagerV1.epochMgr.Params = params
 
-	ibl, err := sm.indexBlobManagerV0.listActiveIndexBlobs(ctx)
+	ibl, _, err := sm.indexBlobManagerV0.listActiveIndexBlobs(ctx)
 	if err != nil {
 		return errors.Wrap(err, "error listing active index blobs")
 	}
diff --git a/repo/content/internal_logger.go b/repo/content/internal_logger.go
index 1157b12e..ecd2f5f7 100644
--- a/repo/content/internal_logger.go
+++ b/repo/content/internal_logger.go
@@ -19,6 +19,9 @@
 
 const blobLoggerFlushThreshold = 4 << 20
 
+// TextLogBlobPrefix is a prefix given to text logs stored in repositor.
+const TextLogBlobPrefix = "_log_"
+
 type internalLogManager struct {
 	ctx            context.Context
 	st             blob.Storage
@@ -60,7 +63,7 @@ func (m *internalLogManager) NewLogger() *internalLogger {
 
 	return &internalLogger{
 		m:      m,
-		prefix: blob.ID(fmt.Sprintf("_log_%v_%x", clock.Now().Local().Format("20060102150405"), rnd)),
+		prefix: blob.ID(fmt.Sprintf("%v%v_%x", TextLogBlobPrefix, clock.Now().Local().Format("20060102150405"), rnd)),
 	}
 }
 
diff --git a/repo/maintenance/maintenance_run.go b/repo/maintenance/maintenance_run.go
index 8dca2505..4ae48a34 100644
--- a/repo/maintenance/maintenance_run.go
+++ b/repo/maintenance/maintenance_run.go
@@ -204,12 +204,17 @@ func Run(ctx context.Context, runParams RunParameters, safety SafetyParameters)
 }
 
 func runQuickMaintenance(ctx context.Context, runParams RunParameters, safety SafetyParameters) error {
+	if _, ok := runParams.rep.ContentManager().EpochManager(); ok {
+		log(ctx).Debugf("quick maintenance not required for epoch manager")
+		return nil
+	}
+
 	s, err := GetSchedule(ctx, runParams.rep)
 	if err != nil {
 		return errors.Wrap(err, "unable to get schedule")
 	}
 
-	if shouldQuickRewriteContents(s) {
+	if shouldQuickRewriteContents(s, safety) {
 		// find 'q' packs that are less than 80% full and rewrite contents in them into
 		// new consolidated packs, orphaning old packs in the process.
 		if err := runTaskRewriteContentsQuick(ctx, runParams, s, safety); err != nil {
@@ -341,13 +346,7 @@ func runFullMaintenance(ctx context.Context, runParams RunParameters, safety Saf
 		return errors.Wrap(err, "unable to get schedule")
 	}
 
-	// rewrite indexes by dropping content entries that have been marked
-	// as deleted for a long time
-	if err := runTaskDropDeletedContentsFull(ctx, runParams, s, safety); err != nil {
-		return errors.Wrap(err, "error dropping deleted contents")
-	}
-
-	if shouldFullRewriteContents(s) {
+	if shouldFullRewriteContents(s, safety) {
 		// find packs that are less than 80% full and rewrite contents in them into
 		// new consolidated packs, orphaning old packs in the process.
 		if err := runTaskRewriteContentsFull(ctx, runParams, s, safety); err != nil {
@@ -357,6 +356,12 @@ func runFullMaintenance(ctx context.Context, runParams RunParameters, safety Saf
 		notRewritingContents(ctx)
 	}
 
+	// rewrite indexes by dropping content entries that have been marked
+	// as deleted for a long time
+	if err := runTaskDropDeletedContentsFull(ctx, runParams, s, safety); err != nil {
+		return errors.Wrap(err, "error dropping deleted contents")
+	}
+
 	if shouldDeleteOrphanedPacks(runParams.rep.Time(), s, safety) {
 		// delete orphaned packs after some time.
 		if err := runTaskDeleteOrphanedBlobsFull(ctx, runParams, s, safety); err != nil {
@@ -366,18 +371,22 @@ func runFullMaintenance(ctx context.Context, runParams RunParameters, safety Saf
 		notDeletingOrphanedBlobs(ctx, s, safety)
 	}
 
+	if err := runTaskCleanupLogs(ctx, runParams, s); err != nil {
+		return errors.Wrap(err, "error cleaning up logs")
+	}
+
 	return nil
 }
 
 // shouldRewriteContents returns true if it's currently ok to rewrite contents.
 // since each content rewrite will require deleting of orphaned blobs after some time passes,
 // we don't want to starve blob deletion by constantly doing rewrites.
-func shouldQuickRewriteContents(s *Schedule) bool {
+func shouldQuickRewriteContents(s *Schedule, safety SafetyParameters) bool {
 	latestContentRewriteEndTime := maxEndTime(s.Runs[TaskRewriteContentsFull], s.Runs[TaskRewriteContentsQuick])
 	latestBlobDeleteTime := maxEndTime(s.Runs[TaskDeleteOrphanedBlobsFull], s.Runs[TaskDeleteOrphanedBlobsQuick])
 
 	// never did rewrite - safe to do so.
-	if latestContentRewriteEndTime.IsZero() {
+	if latestContentRewriteEndTime.IsZero() || safety.MinRewriteToOrphanDeletionDelay == 0 {
 		return true
 	}
 
@@ -387,14 +396,14 @@ func shouldQuickRewriteContents(s *Schedule) bool {
 // shouldFullRewriteContents returns true if it's currently ok to rewrite contents.
 // since each content rewrite will require deleting of orphaned blobs after some time passes,
 // we don't want to starve blob deletion by constantly doing rewrites.
-func shouldFullRewriteContents(s *Schedule) bool {
+func shouldFullRewriteContents(s *Schedule, safety SafetyParameters) bool {
 	// NOTE - we're not looking at TaskRewriteContentsQuick here, this allows full rewrite to sometimes
 	// follow quick rewrite.
 	latestContentRewriteEndTime := maxEndTime(s.Runs[TaskRewriteContentsFull])
 	latestBlobDeleteTime := maxEndTime(s.Runs[TaskDeleteOrphanedBlobsFull], s.Runs[TaskDeleteOrphanedBlobsQuick])
 
 	// never did rewrite - safe to do so.
-	if latestContentRewriteEndTime.IsZero() {
+	if latestContentRewriteEndTime.IsZero() || safety.MinRewriteToOrphanDeletionDelay == 0 {
 		return true
 	}
 


Test patch:
diff --git a/internal/epoch/epoch_manager_test.go b/internal/epoch/epoch_manager_test.go
index 0f151022..3f9f6556 100644
--- a/internal/epoch/epoch_manager_test.go
+++ b/internal/epoch/epoch_manager_test.go
@@ -185,7 +185,7 @@ func TestIndexEpochManager_Parallel(t *testing.T) {
 
 				writtenEntries = append(writtenEntries, indexNum)
 
-				blobs, err := te2.mgr.GetCompleteIndexSet(ctx, LatestEpoch)
+				blobs, _, err := te2.mgr.GetCompleteIndexSet(ctx, LatestEpoch)
 				if err != nil {
 					return errors.Wrap(err, "GetCompleteIndexSet")
 				}
@@ -260,6 +260,7 @@ func TestIndexEpochManager_RogueBlobs(t *testing.T) {
 	te.data[EpochMarkerIndexBlobPrefix+"zzzz"] = []byte{1}
 	te.data[SingleEpochCompactionBlobPrefix+"zzzz"] = []byte{1}
 	te.data[RangeCheckpointIndexBlobPrefix+"zzzz"] = []byte{1}
+	te.data[DeletionWatermarkBlobPrefix+"zzzz"] = []byte{1}
 
 	verifySequentialWrites(t, te)
 	te.mgr.Cleanup(testlogging.Context(t))
@@ -381,7 +382,7 @@ func TestGetCompleteIndexSetRetriesIfTookTooLong(t *testing.T) {
 		},
 	}
 
-	_, err := te.mgr.GetCompleteIndexSet(ctx, 0)
+	_, _, err := te.mgr.GetCompleteIndexSet(ctx, 0)
 	require.NoError(t, err)
 
 	require.EqualValues(t, 1, *te.mgr.getCompleteIndexSetTooSlow)
@@ -407,7 +408,7 @@ func TestSlowWrite(t *testing.T) {
 	te.mustWriteIndexFile(ctx, t, newFakeIndexWithEntries(1))
 	require.EqualValues(t, 11, *te.mgr.writeIndexTooSlow)
 	te.mustWriteIndexFile(ctx, t, newFakeIndexWithEntries(2))
-	te.verifyCompleteIndexSet(ctx, t, LatestEpoch, newFakeIndexWithEntries(1, 2))
+	te.verifyCompleteIndexSet(ctx, t, LatestEpoch, newFakeIndexWithEntries(1, 2), time.Time{})
 }
 
 func TestForceAdvanceEpoch(t *testing.T) {
@@ -439,6 +440,7 @@ func verifySequentialWrites(t *testing.T, te *epochManagerTestEnv) {
 	endTime := te.ft.NowFunc()().Add(90 * 24 * time.Hour)
 
 	indexNum := 1
+	lastDeletionWatermark := time.Time{}
 
 	for te.ft.NowFunc()().Before(endTime) {
 		indexNum++
@@ -446,7 +448,7 @@ func verifySequentialWrites(t *testing.T, te *epochManagerTestEnv) {
 		te.mustWriteIndexFile(ctx, t, newFakeIndexWithEntries(indexNum))
 
 		expected.Entries = append(expected.Entries, indexNum)
-		te.verifyCompleteIndexSet(ctx, t, LatestEpoch, expected)
+		te.verifyCompleteIndexSet(ctx, t, LatestEpoch, expected, lastDeletionWatermark)
 
 		dt := randomTime(1*time.Minute, 8*time.Hour)
 		t.Logf("advancing time by %v", dt)
@@ -460,6 +462,13 @@ func verifySequentialWrites(t *testing.T, te *epochManagerTestEnv) {
 			// do not require.NoError because we'll be sometimes inducing faults
 			te.mgr.Cleanup(ctx)
 		}
+
+		if indexNum%13 == 0 {
+			ts := te.ft.NowFunc()().Truncate(time.Second)
+			require.NoError(t, te.mgr.AdvanceDeletionWatermark(ctx, ts))
+			require.Error(t, te.mgr.AdvanceDeletionWatermark(ctx, ts.Add(-time.Second)))
+			lastDeletionWatermark = ts
+		}
 	}
 
 	te.mgr.Flush()
@@ -493,20 +502,95 @@ func TestIndexEpochManager_RefreshContextCanceled(t *testing.T) {
 	require.ErrorIs(t, err, ctx.Err())
 }
 
+func TestValidateParameters(t *testing.T) {
+	cases := []struct {
+		p       Parameters
+		wantErr string
+	}{
+		{DefaultParameters, ""},
+		{
+			Parameters{
+				Enabled: false,
+			}, "",
+		},
+		{
+			Parameters{
+				Enabled:          true,
+				MinEpochDuration: 1 * time.Second,
+			}, "minimum epoch duration too low: 1s",
+		},
+		{
+			Parameters{
+				Enabled:               true,
+				MinEpochDuration:      1 * time.Hour,
+				EpochRefreshFrequency: 30 * time.Minute,
+			}, "epoch refresh frequency too high, must be 1/3 or minimal epoch duration or less",
+		},
+		{
+			Parameters{
+				Enabled:                 true,
+				MinEpochDuration:        1 * time.Hour,
+				EpochRefreshFrequency:   10 * time.Minute,
+				FullCheckpointFrequency: -1,
+			}, "invalid epoch checkpoint frequency",
+		},
+		{
+			Parameters{
+				Enabled:                 true,
+				MinEpochDuration:        1 * time.Hour,
+				EpochRefreshFrequency:   10 * time.Minute,
+				FullCheckpointFrequency: 5,
+				CleanupSafetyMargin:     15 * time.Minute,
+			}, "invalid cleanup safety margin, must be at least 3x epoch refresh frequency",
+		},
+		{
+			Parameters{
+				Enabled:                      true,
+				MinEpochDuration:             1 * time.Hour,
+				EpochRefreshFrequency:        10 * time.Minute,
+				FullCheckpointFrequency:      5,
+				CleanupSafetyMargin:          time.Hour,
+				EpochAdvanceOnCountThreshold: 1,
+			}, "epoch advance on count too low",
+		},
+		{
+			Parameters{
+				Enabled:                      true,
+				MinEpochDuration:             1 * time.Hour,
+				EpochRefreshFrequency:        10 * time.Minute,
+				FullCheckpointFrequency:      5,
+				CleanupSafetyMargin:          time.Hour,
+				EpochAdvanceOnCountThreshold: 10,
+			}, "epoch advance on size too low",
+		},
+	}
+
+	for _, tc := range cases {
+		err := tc.p.Validate()
+		if tc.wantErr != "" {
+			require.Error(t, err)
+			require.Contains(t, err.Error(), tc.wantErr)
+		} else {
+			require.NoError(t, err)
+		}
+	}
+}
+
 func randomTime(min, max time.Duration) time.Duration {
 	return time.Duration(float64(max-min)*rand.Float64() + float64(min))
 }
 
-func (te *epochManagerTestEnv) verifyCompleteIndexSet(ctx context.Context, t *testing.T, maxEpoch int, want *fakeIndex) {
+func (te *epochManagerTestEnv) verifyCompleteIndexSet(ctx context.Context, t *testing.T, maxEpoch int, want *fakeIndex, wantDeletionWatermark time.Time) {
 	t.Helper()
 
-	blobs, err := te.mgr.GetCompleteIndexSet(ctx, maxEpoch)
+	blobs, deletionWatermark, err := te.mgr.GetCompleteIndexSet(ctx, maxEpoch)
 	t.Logf("complete set length: %v", len(blobs))
 	require.NoError(t, err)
 
 	merged, err := te.getMergedIndexContents(ctx, blob.IDsFromMetadata(blobs))
 	require.NoError(t, err)
 	require.Equal(t, want.Entries, merged.Entries)
+	require.True(t, wantDeletionWatermark.Equal(deletionWatermark), "invalid deletion watermark %v %v", deletionWatermark, wantDeletionWatermark)
 }
 
 func (te *epochManagerTestEnv) getMergedIndexContents(ctx context.Context, blobIDs []blob.ID) (*fakeIndex, error) {
diff --git a/repo/content/index_blob_manager_v0_test.go b/repo/content/index_blob_manager_v0_test.go
index 0a51ce97..346bde98 100644
--- a/repo/content/index_blob_manager_v0_test.go
+++ b/repo/content/index_blob_manager_v0_test.go
@@ -646,7 +646,7 @@ func getAllFakeContents(ctx context.Context, t *testing.T, m *indexBlobManagerV0
 func getAllFakeContentsInternal(ctx context.Context, t *testing.T, m *indexBlobManagerV0) (map[string]fakeContentIndexEntry, []IndexBlobInfo, error) {
 	t.Helper()
 
-	blobs, err := m.listActiveIndexBlobs(ctx)
+	blobs, _, err := m.listActiveIndexBlobs(ctx)
 	if err != nil {
 		return nil, nil, errors.Wrap(err, "error listing index blobs")
 	}
@@ -738,7 +738,7 @@ func assertIndexBlobList(t *testing.T, m *indexBlobManagerV0, wantMD ...blob.Met
 		want = append(want, it.BlobID)
 	}
 
-	l, err := m.listActiveIndexBlobs(testlogging.Context(t))
+	l, _, err := m.listActiveIndexBlobs(testlogging.Context(t))
 	if err != nil {
 		t.Fatalf("failed to list index blobs: %v", err)
 	}
diff --git a/repo/maintenance/maintenance_run_test.go b/repo/maintenance/maintenance_run_test.go
index 2592a226..9e6166be 100644
--- a/repo/maintenance/maintenance_run_test.go
+++ b/repo/maintenance/maintenance_run_test.go
@@ -82,6 +82,7 @@ func TestShouldDeleteOrphanedBlobs(t *testing.T) {
 func TestShouldRewriteContents(t *testing.T) {
 	cases := []struct {
 		runs      map[TaskType][]RunInfo
+		safety    SafetyParameters
 		wantFull  bool
 		wantQuick bool
 	}{
@@ -99,6 +100,7 @@ func TestShouldRewriteContents(t *testing.T) {
 					RunInfo{Success: true, End: t0700},
 				},
 			},
+			safety:    SafetyFull,
 			wantFull:  true,
 			wantQuick: true,
 		},
@@ -111,9 +113,23 @@ func TestShouldRewriteContents(t *testing.T) {
 					RunInfo{Success: true, End: t0715},
 				},
 			},
+			safety:    SafetyFull,
 			wantFull:  false,
 			wantQuick: false,
 		},
+		{
+			runs: map[TaskType][]RunInfo{
+				TaskDeleteOrphanedBlobsQuick: {
+					RunInfo{Success: true, End: t0700},
+				},
+				TaskRewriteContentsFull: {
+					RunInfo{Success: true, End: t0715},
+				},
+			},
+			safety:    SafetyNone,
+			wantFull:  true,
+			wantQuick: true,
+		},
 		{
 			runs: map[TaskType][]RunInfo{
 				TaskDeleteOrphanedBlobsQuick: {
@@ -123,9 +139,23 @@ func TestShouldRewriteContents(t *testing.T) {
 					RunInfo{Success: true, End: t0715},
 				},
 			},
+			safety:    SafetyFull,
 			wantFull:  true, // will be allowed despite quick run having just finished
 			wantQuick: false,
 		},
+		{
+			runs: map[TaskType][]RunInfo{
+				TaskDeleteOrphanedBlobsQuick: {
+					RunInfo{Success: true, End: t0700},
+				},
+				TaskRewriteContentsQuick: {
+					RunInfo{Success: true, End: t0715},
+				},
+			},
+			safety:    SafetyNone,
+			wantFull:  true, // will be allowed despite quick run having just finished
+			wantQuick: true,
+		},
 		{
 			runs: map[TaskType][]RunInfo{
 				TaskDeleteOrphanedBlobsQuick: {
@@ -135,6 +165,7 @@ func TestShouldRewriteContents(t *testing.T) {
 					RunInfo{Success: true, End: t0700},
 				},
 			},
+			safety:    SafetyFull,
 			wantFull:  true,
 			wantQuick: true,
 		},
@@ -143,10 +174,10 @@ func TestShouldRewriteContents(t *testing.T) {
 	for _, tc := range cases {
 		require.Equal(t, tc.wantQuick, shouldQuickRewriteContents(&Schedule{
 			Runs: tc.runs,
-		}))
+		}, tc.safety), tc.runs)
 		require.Equal(t, tc.wantFull, shouldFullRewriteContents(&Schedule{
 			Runs: tc.runs,
-		}))
+		}, tc.safety), tc.runs)
 	}
 }
 
diff --git a/tests/end_to_end_test/index_optimize_test.go b/tests/end_to_end_test/index_optimize_test.go
index 5fbeea25..9d2889d4 100644
--- a/tests/end_to_end_test/index_optimize_test.go
+++ b/tests/end_to_end_test/index_optimize_test.go
@@ -1,6 +1,7 @@
 package endtoend_test
 
 import (
+	"strings"
 	"testing"
 
 	"github.com/kopia/kopia/tests/testenv"
@@ -16,6 +17,12 @@ func TestIndexOptimize(t *testing.T) {
 
 	e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
 
+	for _, line := range e.RunAndExpectSuccess(t, "repository", "status") {
+		if strings.HasPrefix(line, "Epoch Manager:") && strings.Contains(line, "enabled") {
+			t.Skip()
+		}
+	}
+
 	e.RunAndExpectSuccess(t, "snapshot", "create", ".")
 
 	e.RunAndExpectSuccess(t, "snapshot", "create", sharedTestDataDir1)
diff --git a/tests/end_to_end_test/maintenance_test.go b/tests/end_to_end_test/maintenance_test.go
index 466e18fb..6f7054b1 100644
--- a/tests/end_to_end_test/maintenance_test.go
+++ b/tests/end_to_end_test/maintenance_test.go
@@ -20,11 +20,14 @@ func TestFullMaintenance(t *testing.T) {
 
 	var snap snapshot.Manifest
 
-	// after creation we'll have kopia.repository, 1 index + 1 pack blob
-	if got, want := e.RunAndExpectSuccess(t, "blob", "list"), 3; len(got) != want {
+	// after creation we'll have 1 pack blob
+	if got, want := e.RunAndExpectSuccess(t, "blob", "list", "--data-only"), 1; len(got) != want {
 		t.Fatalf("unexpected number of initial blobs: %v, want %v", got, want)
 	}
 
+	beforeSnapshotBlobs := e.RunAndExpectSuccess(t, "blob", "list", "--data-only")
+	_ = beforeSnapshotBlobs
+
 	testutil.MustParseJSONLines(t, e.RunAndExpectSuccess(t, "snapshot", "create", sharedTestDataDir1, "--json", "--disable-internal-log"), &snap)
 
 	// avoid create and delete in the same second.
@@ -33,30 +36,28 @@ func TestFullMaintenance(t *testing.T) {
 
 	e.RunAndVerifyOutputLineCount(t, 0, "snapshot", "list")
 
-	originalBlobCount := len(e.RunAndExpectSuccess(t, "blob", "list"))
+	originalBlobs := e.RunAndExpectSuccess(t, "blob", "list", "--data-only")
 
 	e.RunAndVerifyOutputLineCount(t, 0, "maintenance", "run", "--full", "--disable-internal-log")
 
-	if got := len(e.RunAndExpectSuccess(t, "blob", "list")); got != originalBlobCount {
-		t.Fatalf("full maintenance is not expected to change any blobs due to safety margins (got %v, was %v)", got, originalBlobCount)
+	if got := e.RunAndExpectSuccess(t, "blob", "list", "--data-only"); len(got) != len(originalBlobs) {
+		t.Fatalf("full maintenance is not expected to change any blobs due to safety margins (got %v, was %v)", got, originalBlobs)
 	}
 
 	// now rerun with --safety=none
 	e.RunAndExpectSuccess(t, "maintenance", "run", "--full", "--safety=none", "--disable-internal-log")
 
-	if got := len(e.RunAndExpectSuccess(t, "blob", "list")); got >= originalBlobCount {
-		t.Fatalf("maintenance did not remove blobs: %v, had %v", got, originalBlobCount)
+	if got := e.RunAndExpectSuccess(t, "blob", "list", "--data-only"); len(got) >= len(originalBlobs) {
+		t.Fatalf("maintenance did not remove blobs: %v, had %v", got, originalBlobs)
 	}
 
-	// we're expecting to have 5 or 6 blobs:
-	// - kopia.maintenance
-	// - kopia.repository
-	// - 2 index blobs
-	// - 1 or 2 q blob
+	e.RunAndExpectSuccess(t, "content", "list", "-l")
+
+	// we're expecting to have 1 or 2 q blob
 
-	const blobCountAfterFullWipeout = 6
+	const blobCountAfterFullWipeout = 2
 
-	if got, want := e.RunAndExpectSuccess(t, "blob", "list"), blobCountAfterFullWipeout; len(got) > want {
+	if got, want := e.RunAndExpectSuccess(t, "blob", "list", "--data-only"), blobCountAfterFullWipeout; len(got) > want {
 		t.Fatalf("maintenance left unwanted blobs: %v, want %v", got, want)
 	}
 }
diff --git a/tests/testenv/cli_test_env.go b/tests/testenv/cli_test_env.go
index 266f55dd..00d63897 100644
--- a/tests/testenv/cli_test_env.go
+++ b/tests/testenv/cli_test_env.go
@@ -4,6 +4,7 @@
 import (
 	"bufio"
 	"io"
+	"os"
 	"path/filepath"
 	"runtime"
 	"strings"
@@ -185,6 +186,10 @@ func (e *CLITest) Run(t *testing.T, expectedError bool, args ...string) (stdout,
 
 		scanner := bufio.NewScanner(stdoutReader)
 		for scanner.Scan() {
+			if os.Getenv("KOPIA_TEST_LOG_OUTPUT") != "" {
+				t.Logf("[stdout] %v", scanner.Text())
+			}
+
 			stdout = append(stdout, scanner.Text())
 		}
 	}()
@@ -196,6 +201,10 @@ func (e *CLITest) Run(t *testing.T, expectedError bool, args ...string) (stdout,
 
 		scanner := bufio.NewScanner(stderrReader)
 		for scanner.Scan() {
+			if os.Getenv("KOPIA_TEST_LOG_OUTPUT") != "" {
+				t.Logf("[stderr] %v", scanner.Text())
+			}
+
 			stderr = append(stderr, scanner.Text())
 		}
 	}()