Repository: kopia/kopia
Pull request #3735 (2024-03-15 05:50:33)

refactor(general): closedIntRange
Refactoring for the original implementation with `intRange` and `getKeyRange` from _closed-open_ ranges `[lo, hi)` to closed ranges: `[lo, hi]`. The primary motivation is for consistency with the implementation of `epoch.RangeMetadata` in the same package, and thus  avoid confusion and reduce cognitive load.

Changes:
- adds a `getContiguousKeyRange` wrapper that checks for contiguity.
- `getKeyRange` simply returns a range with minimum and maximum values for the keys in the map.
- changes the range implementation from _closed-open_ ranges `[lo, hi)` to closed ranges: `[lo, hi]` where both lo and hi are included in the range. Additional unit tests are included.
- renames `intRange` to `closedIntRange` to reflect new functionality.

Ref:
- Follow up #3721
- Needed for #3728
- #3638


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/kopia/kopia/pull/3735?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 77.08%. Comparing base [(`cb455c6`)](https://app.codecov.io/gh/kopia/kopia/commit/cb455c699a04e54cb47e25a676d7d65063ce883e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) to head [(`00db77a`)](https://app.codecov.io/gh/kopia/kopia/pull/3735?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).
> Report is 59 commits behind head on master.


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #3735      +/-   ##
==========================================
+ Coverage   75.86%   77.08%   +1.21%     
==========================================
  Files         470      476       +6     
  Lines       37301    28793    -8508     
==========================================
- Hits        28299    22194    -6105     
+ Misses       7071     4676    -2395     
+ Partials     1931     1923       -8     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/kopia/kopia/pull/3735?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).


Patch:
diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml
index 95bc5b99..37df1a8d 100644
--- a/.github/workflows/auto-merge.yml
+++ b/.github/workflows/auto-merge.yml
@@ -7,7 +7,7 @@ jobs:
   auto-merge:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       - uses: ahmadnassri/action-dependabot-auto-merge@v2
         with:
           # auto-merge rules are in /.github/auto-merge.yml
diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml
index bc907e7f..28593d86 100644
--- a/.github/workflows/code-coverage.yml
+++ b/.github/workflows/code-coverage.yml
@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 5e441537..dc495d15 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -15,6 +15,6 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: 'Checkout Repository'
-        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+        uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       - name: 'Dependency Review'
         uses: actions/dependency-review-action@v4
diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml
index a954a7b6..9cbe148c 100644
--- a/.github/workflows/license-check.yml
+++ b/.github/workflows/license-check.yml
@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 53e59c3f..7b81e1d7 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -26,7 +26,7 @@ jobs:
     runs-on: ${{ matrix.os }}
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
diff --git a/.github/workflows/make.yml b/.github/workflows/make.yml
index 687c2c0d..5ab0d0c1 100644
--- a/.github/workflows/make.yml
+++ b/.github/workflows/make.yml
@@ -40,7 +40,7 @@ jobs:
     continue-on-error: ${{ contains(matrix.os, 'self-hosted') }}
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
@@ -139,11 +139,11 @@ jobs:
     needs: build
     if: github.event_name != 'pull_request' && github.repository == 'kopia/kopia'
     steps:
-    - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+    - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
     - name: Set up QEMU
       uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
     - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@0d103c3126aa41d772a8362f6aa67afac040f80c # v3.1.0
+      uses: docker/setup-buildx-action@2b51285047da1547ffb1b2203d8be4c0af6b1f20 # v3.2.0
     - name: Download Artifacts
       uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
       with:
diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml
index c77e5fa0..f59a22d0 100644
--- a/.github/workflows/ossf-scorecard.yml
+++ b/.github/workflows/ossf-scorecard.yml
@@ -26,7 +26,7 @@ jobs:
     steps:
       -
         name: "Checkout repo"
-        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+        uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
         with:
           persist-credentials: false
       -
@@ -39,7 +39,7 @@ jobs:
       -
         # Upload the results to GitHub's code scanning dashboard.
         name: "Upload to results to dashboard"
-        uses: github/codeql-action/upload-sarif@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
+        uses: github/codeql-action/upload-sarif@05963f47d870e2cb19a537396c1f668a348c7d8f # v3.24.8
         with:
           sarif_file: results.sarif
       -
diff --git a/.github/workflows/providers-core.yml b/.github/workflows/providers-core.yml
index 6f3a94ee..de50ab5d 100644
--- a/.github/workflows/providers-core.yml
+++ b/.github/workflows/providers-core.yml
@@ -24,7 +24,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
         ref: ${{ github.event.inputs.ref_name || github.ref }}
diff --git a/.github/workflows/providers-extra.yml b/.github/workflows/providers-extra.yml
index 8eab99e7..4923dcdb 100644
--- a/.github/workflows/providers-extra.yml
+++ b/.github/workflows/providers-extra.yml
@@ -24,7 +24,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
         ref: ${{ github.event.inputs.ref_name || github.ref }}
diff --git a/.github/workflows/race-detector.yml b/.github/workflows/race-detector.yml
index e4e33de5..df10f33c 100644
--- a/.github/workflows/race-detector.yml
+++ b/.github/workflows/race-detector.yml
@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
diff --git a/go.mod b/go.mod
index 7a9b13d3..9817ecc4 100644
--- a/go.mod
+++ b/go.mod
@@ -47,7 +47,7 @@ require (
 	github.com/stretchr/testify v1.9.0
 	github.com/studio-b12/gowebdav v0.9.0
 	github.com/tg123/go-htpasswd v1.2.2
-	github.com/zalando/go-keyring v0.2.3
+	github.com/zalando/go-keyring v0.2.4
 	github.com/zeebo/blake3 v0.2.3
 	go.opentelemetry.io/otel v1.24.0
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0
@@ -64,7 +64,7 @@ require (
 	golang.org/x/sys v0.18.0
 	golang.org/x/term v0.18.0
 	golang.org/x/text v0.14.0
-	google.golang.org/api v0.167.0
+	google.golang.org/api v0.170.0
 	google.golang.org/grpc v1.62.1
 	google.golang.org/protobuf v1.33.0
 	gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216
@@ -126,8 +126,8 @@ require (
 	github.com/rs/xid v1.5.0 // indirect
 	github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
 	go.opencensus.io v0.24.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.48.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect
 	go.opentelemetry.io/otel/metric v1.24.0 // indirect
 	go.opentelemetry.io/proto/otlp v1.1.0 // indirect
@@ -135,7 +135,7 @@ require (
 	google.golang.org/appengine v1.6.8 // indirect
 	google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20240304161311-37d4d3c04a78 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20240228224816-df926f6c8641 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index e6afbbcc..b895015e 100644
--- a/go.sum
+++ b/go.sum
@@ -67,8 +67,6 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa h1:jQCWAUqqlij9Pgj2i/PB79y4KOPYVyFYdROxgaCwdTQ=
-github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa/go.mod h1:x/1Gn8zydmfq8dk6e9PdstVsDgu9RuyIIJqAaF//0IM=
 github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -90,8 +88,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A=
-github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
 github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
@@ -298,8 +294,8 @@ github.com/tg123/go-htpasswd v1.2.2/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/
 github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
-github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
+github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
+github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
 github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
 github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
 github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg=
@@ -308,10 +304,10 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
 github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.48.0 h1:P+/g8GpuJGYbOp2tAdKrIPUX9JO02q8Q0YNlHolpibA=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.48.0/go.mod h1:tIKj3DbO8N9Y2xo52og3irLsPI4GW02DSMtrVgNMgxg=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 h1:doUP+ExOpH3spVTLS0FcWGLnQrPct/hD/bCPbDRUEAU=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0/go.mod h1:rdENBZMT2OE6Ne/KLwpiXudnAsbdrdBaqBvTN8M8BgA=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
 go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
 go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
@@ -419,8 +415,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
-google.golang.org/api v0.167.0 h1:CKHrQD1BLRii6xdkatBDXyKzM0mkawt2QP+H3LtPmSE=
-google.golang.org/api v0.167.0/go.mod h1:4FcBc686KFi7QI/U51/2GKKevfZMpM17sCdibqe/bSA=
+google.golang.org/api v0.170.0 h1:zMaruDePM88zxZBG+NG8+reALO2rfLhe/JShitLyT48=
+google.golang.org/api v0.170.0/go.mod h1:/xql9M2btF85xac/VAm4PsLMTLVGUOpq4BE9R8jyNy8=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
@@ -432,8 +428,8 @@ google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJ
 google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s=
 google.golang.org/genproto/googleapis/api v0.0.0-20240304161311-37d4d3c04a78 h1:SzXBGiWM1LNVYLCRP3e0/Gsze804l4jGoJ5lYysEO5I=
 google.golang.org/genproto/googleapis/api v0.0.0-20240304161311-37d4d3c04a78/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240228224816-df926f6c8641 h1:DKU1r6Tj5s1vlU/moGhuGz7E3xRfwjdAfDzbsaQJtEY=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240228224816-df926f6c8641/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 h1:9IZDv+/GcI6u+a4jRFRLxQs0RUCfavGfoOgEW6jpkI0=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
diff --git a/internal/epoch/epoch_utils.go b/internal/epoch/epoch_utils.go
index 6d969c63..0e184934 100644
--- a/internal/epoch/epoch_utils.go
+++ b/internal/epoch/epoch_utils.go
@@ -94,15 +94,23 @@ func deletionWatermarkFromBlobID(blobID blob.ID) (time.Time, bool) {
 	return time.Unix(unixSeconds, 0), true
 }
 
-type intRange struct {
+// closedIntRange represents a discrete closed-closed [lo, hi] range for ints.
+// That is, the range includes both lo and hi.
+type closedIntRange struct {
 	lo, hi int
 }
 
-func (r intRange) length() uint {
-	return uint(r.hi - r.lo)
+func (r closedIntRange) length() uint {
+	// any range where lo > hi is empty. The canonical empty representation
+	// is {lo:0, hi: -1}
+	if r.lo > r.hi {
+		return 0
+	}
+
+	return uint(r.hi - r.lo + 1)
 }
 
-func (r intRange) isEmpty() bool {
+func (r closedIntRange) isEmpty() bool {
 	return r.length() == 0
 }
 
@@ -117,14 +125,13 @@ func (r intRange) isEmpty() bool {
 	minInt = -1 << (intSize - 1)
 )
 
-// Returns a continuous close-open epoch range for the keys, that is [lo, hi).
-// A range of the form [v,v) means the range is empty.
-// When the range is not continuous an error is returned.
-func getKeyRange[E any](m map[int]E) (intRange, error) {
-	var count uint
+// Returns a range for the keys in m. It returns an empty range when m is empty.
+func getKeyRange[E any](m map[int]E) closedIntRange {
+	if len(m) == 0 {
+		return closedIntRange{lo: 0, hi: -1}
+	}
 
 	lo, hi := maxInt, minInt
-
 	for k := range m {
 		if k < lo {
 			lo = k
@@ -133,20 +140,23 @@ func getKeyRange[E any](m map[int]E) (intRange, error) {
 		if k > hi {
 			hi = k
 		}
-
-		count++
 	}
 
-	if count == 0 {
-		return intRange{}, nil
-	}
+	return closedIntRange{lo: lo, hi: hi}
+}
 
-	// hi and lo are from unique map keys, so for the range to be continuous
-	// the difference between hi and lo cannot be larger than count -1.
-	// For example, if lo==2, hi==4, and count == 3, the range must be contiguous => {2, 3, 4}.
-	if uint(hi-lo) > count-1 {
-		return intRange{}, errors.Wrapf(errNonContiguousRange, "[lo: %d, hi: %d], length: %d", lo, hi, count)
+// Returns a contiguous range for the keys in m.
+// When the range is not continuous an error is returned.
+func getContiguousKeyRange[E any](m map[int]E) (closedIntRange, error) {
+	r := getKeyRange(m)
+
+	// r.hi and r.lo are from unique map keys, so for the range to be continuous
+	// then the range length must be exactly the same as the size of the map.
+	// For example, if lo==2, hi==4, and len(m) == 3, the range must be
+	// contiguous => {2, 3, 4}
+	if r.length() != uint(len(m)) {
+		return closedIntRange{-1, -2}, errors.Wrapf(errNonContiguousRange, "[lo: %d, hi: %d], length: %d", r.lo, r.hi, len(m))
 	}
 
-	return intRange{lo: lo, hi: hi + 1}, nil
+	return r, nil
 }


Test patch:
diff --git a/.github/workflows/compat-test.yml b/.github/workflows/compat-test.yml
index f06fcd75..db93c0da 100644
--- a/.github/workflows/compat-test.yml
+++ b/.github/workflows/compat-test.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
diff --git a/.github/workflows/endurance-test.yml b/.github/workflows/endurance-test.yml
index 83dc6039..539a78a4 100644
--- a/.github/workflows/endurance-test.yml
+++ b/.github/workflows/endurance-test.yml
@@ -19,7 +19,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
diff --git a/.github/workflows/htmlui-tests.yml b/.github/workflows/htmlui-tests.yml
index 78fb032f..dbc44f15 100644
--- a/.github/workflows/htmlui-tests.yml
+++ b/.github/workflows/htmlui-tests.yml
@@ -27,7 +27,7 @@ jobs:
     runs-on: macos-latest
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
diff --git a/.github/workflows/stress-test.yml b/.github/workflows/stress-test.yml
index 533ba247..3855b4e1 100644
--- a/.github/workflows/stress-test.yml
+++ b/.github/workflows/stress-test.yml
@@ -18,7 +18,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 7893871a..fccec023 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -38,7 +38,7 @@ jobs:
     continue-on-error: ${{ contains(matrix.os, 'self-hosted') }}
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
diff --git a/.github/workflows/volume-shadow-copy-test.yml b/.github/workflows/volume-shadow-copy-test.yml
index d9a6365f..2388460b 100644
--- a/.github/workflows/volume-shadow-copy-test.yml
+++ b/.github/workflows/volume-shadow-copy-test.yml
@@ -15,7 +15,7 @@ jobs:
     runs-on: windows-latest
     steps:
     - name: Check out repository
-      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
       with:
         fetch-depth: 0
     - name: Set up Go
diff --git a/internal/epoch/epoch_utils_test.go b/internal/epoch/epoch_utils_test.go
index 9dd0748a..02579abe 100644
--- a/internal/epoch/epoch_utils_test.go
+++ b/internal/epoch/epoch_utils_test.go
@@ -77,108 +77,116 @@ func TestGroupByEpochNumber(t *testing.T) {
 	}
 }
 
-func TestGetKeyRange(t *testing.T) {
+func TestGetContiguosKeyRange(t *testing.T) {
+	invalidEmptyRange := closedIntRange{-1, -2}
+
 	cases := []struct {
 		input     map[int]bool
-		want      intRange
+		want      closedIntRange
 		shouldErr bool
 		length    uint
 		isEmpty   bool
 	}{
 		{
 			isEmpty: true,
+			want:    closedIntRange{0, -1},
+		},
+		{
+			input:  map[int]bool{0: true},
+			want:   closedIntRange{lo: 0, hi: 0},
+			length: 1,
 		},
 		{
 			input:  map[int]bool{-5: true},
-			want:   intRange{lo: -5, hi: -4},
+			want:   closedIntRange{lo: -5, hi: -5},
 			length: 1,
 		},
 		{
 			input:  map[int]bool{-5: true, -4: true},
-			want:   intRange{lo: -5, hi: -3},
+			want:   closedIntRange{lo: -5, hi: -4},
 			length: 2,
 		},
 		{
 			input:  map[int]bool{0: true},
-			want:   intRange{lo: 0, hi: 1},
+			want:   closedIntRange{lo: 0, hi: 0},
 			length: 1,
 		},
 		{
 			input:  map[int]bool{5: true},
-			want:   intRange{lo: 5, hi: 6},
+			want:   closedIntRange{lo: 5, hi: 5},
 			length: 1,
 		},
 		{
 			input:  map[int]bool{0: true, 1: true},
-			want:   intRange{lo: 0, hi: 2},
+			want:   closedIntRange{lo: 0, hi: 1},
 			length: 2,
 		},
 		{
 			input:  map[int]bool{8: true, 9: true},
-			want:   intRange{lo: 8, hi: 10},
+			want:   closedIntRange{lo: 8, hi: 9},
 			length: 2,
 		},
 		{
 			input:  map[int]bool{1: true, 2: true, 3: true, 4: true, 5: true},
-			want:   intRange{lo: 1, hi: 6},
+			want:   closedIntRange{lo: 1, hi: 5},
 			length: 5,
 		},
 		{
 			input:     map[int]bool{8: true, 10: true},
-			want:      intRange{},
+			want:      invalidEmptyRange,
 			shouldErr: true,
 			isEmpty:   true,
 		},
 		{
 			input:     map[int]bool{1: true, 2: true, 3: true, 5: true},
-			want:      intRange{},
+			want:      invalidEmptyRange,
 			shouldErr: true,
 			isEmpty:   true,
 		},
 		{
 			input:     map[int]bool{-5: true, -7: true},
-			want:      intRange{},
+			want:      invalidEmptyRange,
 			shouldErr: true,
 			isEmpty:   true,
 		},
 		{
 			input:     map[int]bool{0: true, minInt: true},
-			want:      intRange{},
+			want:      invalidEmptyRange,
 			shouldErr: true,
 			isEmpty:   true,
 		},
 		{
 			input:     map[int]bool{0: true, maxInt: true},
-			want:      intRange{},
+			want:      invalidEmptyRange,
 			shouldErr: true,
 			isEmpty:   true,
 		},
 		{
 			input:     map[int]bool{maxInt: true, minInt: true},
-			want:      intRange{},
+			want:      invalidEmptyRange,
 			shouldErr: true,
 			isEmpty:   true,
 		},
 		{
 			input:  map[int]bool{minInt: true},
-			want:   intRange{lo: minInt, hi: minInt + 1},
+			want:   closedIntRange{lo: minInt, hi: minInt},
 			length: 1,
 		},
 		{
 			input:  map[int]bool{maxInt - 1: true},
-			want:   intRange{lo: maxInt - 1, hi: maxInt},
+			want:   closedIntRange{lo: maxInt - 1, hi: maxInt - 1},
 			length: 1,
 		},
 		{
 			input:  map[int]bool{maxInt: true},
-			want:   intRange{lo: maxInt, hi: minInt}, // not representable corner case :(
+			want:   closedIntRange{lo: maxInt, hi: maxInt},
 			length: 1,
 		},
 	}
 
 	for i, tc := range cases {
 		t.Run(fmt.Sprint("case: ", i), func(t *testing.T) {
-			got, err := getKeyRange(tc.input)
+			got, err := getContiguousKeyRange(tc.input)
 			if tc.shouldErr {
 				require.Error(t, err, "input: %v", tc.input)
 			}
@@ -194,3 +202,113 @@ func TestAssertMinMaxIntConstants(t *testing.T) {
 	require.Equal(t, math.MinInt, minInt)
 	require.Equal(t, math.MaxInt, maxInt)
 }
+
+func TestGetKeyRange(t *testing.T) {
+	cases := []struct {
+		input   map[int]bool
+		want    closedIntRange
+		length  uint
+		isEmpty bool
+	}{
+		{
+			isEmpty: true,
+			want:    closedIntRange{lo: 0, hi: -1},
+		},
+		{
+			input:  map[int]bool{0: true},
+			want:   closedIntRange{lo: 0, hi: 0},
+			length: 1,
+		},
+		{
+			input:  map[int]bool{-5: true},
+			want:   closedIntRange{lo: -5, hi: -5},
+			length: 1,
+		},
+		{
+			input:  map[int]bool{-5: true, -4: true},
+			want:   closedIntRange{lo: -5, hi: -4},
+			length: 2,
+		},
+		{
+			input:  map[int]bool{0: true},
+			want:   closedIntRange{lo: 0, hi: 0},
+			length: 1,
+		},
+		{
+			input:  map[int]bool{5: true},
+			want:   closedIntRange{lo: 5, hi: 5},
+			length: 1,
+		},
+		{
+			input:  map[int]bool{0: true, 1: true},
+			want:   closedIntRange{lo: 0, hi: 1},
+			length: 2,
+		},
+		{
+			input:  map[int]bool{8: true, 9: true},
+			want:   closedIntRange{lo: 8, hi: 9},
+			length: 2,
+		},
+		{
+			input:  map[int]bool{1: true, 2: true, 3: true, 4: true, 5: true},
+			want:   closedIntRange{lo: 1, hi: 5},
+			length: 5,
+		},
+		{
+			input:  map[int]bool{8: true, 10: true},
+			want:   closedIntRange{lo: 8, hi: 10},
+			length: 3,
+		},
+		{
+			input:  map[int]bool{1: true, 2: true, 3: true, 5: true},
+			want:   closedIntRange{lo: 1, hi: 5},
+			length: 5,
+		},
+		{
+			input:  map[int]bool{-5: true, -7: true},
+			want:   closedIntRange{lo: -7, hi: -5},
+			length: 3,
+		},
+		{
+			input:  map[int]bool{0: true, minInt: true},
+			want:   closedIntRange{lo: minInt, hi: 0},
+			length: -minInt + 1,
+		},
+		{
+			input:  map[int]bool{0: true, maxInt: true},
+			want:   closedIntRange{lo: 0, hi: maxInt},
+			length: maxInt + 1,
+		},
+		{
+			input:   map[int]bool{maxInt: true, minInt: true},
+			want:    closedIntRange{lo: minInt, hi: maxInt},
+			length:  0, // corner case, not representable :(
+			isEmpty: true,
+		},
+		{
+			input:  map[int]bool{minInt: true},
+			want:   closedIntRange{lo: minInt, hi: minInt},
+			length: 1,
+		},
+		{
+			input:  map[int]bool{maxInt - 1: true},
+			want:   closedIntRange{lo: maxInt - 1, hi: maxInt - 1},
+			length: 1,
+		},
+		{
+			input:  map[int]bool{maxInt: true},
+			want:   closedIntRange{lo: maxInt, hi: maxInt},
+			length: 1,
+		},
+	}
+
+	for i, tc := range cases {
+		t.Run(fmt.Sprint("case: ", i), func(t *testing.T) {
+			got := getKeyRange(tc.input)
+
+			require.Equal(t, tc.want, got, "input: %#v", tc.input)
+			require.Equal(t, tc.length, got.length())
+			require.Equal(t, tc.isEmpty, got.isEmpty())
+		})
+	}
+}