Files
versitygw/backend/azure/azure_test.go
T
Nils LegerandGitHub c638503b90 feat(azure): carry Azure blob marker in continuation token for pagination
* feat(azure): carry Azure blob marker in continuation token for pagination

Azure blob markers are opaque values only Azure may mint, so an S3 object
key can never be passed back to Azure as a listing marker. Paging the
underlying Azure listing with an S3 key therefore failed or re-scanned the
whole prefix on every page.

Introduce azMarkerToken, an opaque S3 continuation token that carries the
Azure marker alongside the last returned key: the Azure marker resumes the
blob listing where it stopped, and the last key filters out already-returned
entries. Tokens are versioned with a "vgw1." prefix; anything without it is
treated as a plain key, so tokens from older versions and hand-crafted
markers keep working. The shared listBlobs helper now backs both ListObjects
and ListObjectsV2, applying the S3 marker and delimiter client side.
ListObjectsV2 pages efficiently via the token; ListObjects (v1) has no token
to carry state and walks the prefix from the start each page.

Add unit tests (token round-trip, marker-resumed pagination, delimiter and
common-prefix handling, multipart filtering) driven by a fake Azure
container, and integration tests covering full and delimited pagination.

Signed-off-by: Nils Leger <nils.leger@getflip.com>

* fix: token is now bound to delimiter too

Signed-off-by: Nils Leger <nils.leger@getflip.com>

---------

Signed-off-by: Nils Leger <nils.leger@getflip.com>
2026-08-27 16:40:43 -07:00

325 lines
8.8 KiB
Go

// Copyright 2026 Versity Software
// This file is licensed 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 azure
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
"github.com/versity/versitygw/backend"
)
func TestDecodeAzMarkerToken(t *testing.T) {
tests := []struct {
name string
token string
prefix string
delimiter string
wantMarker string
wantKey string
}{
{
name: "round trip",
token: encodeAzMarkerToken(azMarkerToken{Prefix: "test/", Delimiter: "/", Marker: "2!68!MDAwMDI4", LastKey: "test/a.js"}),
prefix: "test/",
delimiter: "/",
wantMarker: "2!68!MDAwMDI4",
wantKey: "test/a.js",
},
{
name: "plain key from an older token or a hand written marker",
token: "test/a.js",
prefix: "test/",
wantKey: "test/a.js",
},
{
// the Azure marker belongs to the listing that produced it, so only
// the key survives a prefix change
name: "prefix mismatch drops the azure marker",
token: encodeAzMarkerToken(azMarkerToken{Prefix: "test/", Marker: "2!68!MDAwMDI4", LastKey: "test/a.js"}),
prefix: "media/",
wantKey: "test/a.js",
},
{
// the last key was collapsed under the token's delimiter, so a
// delimiter change drops the azure marker and restarts the listing
name: "delimiter mismatch drops the azure marker",
token: encodeAzMarkerToken(azMarkerToken{Prefix: "test/", Delimiter: "/", Marker: "2!68!MDAwMDI4", LastKey: "test/a.js"}),
prefix: "test/",
delimiter: "",
wantKey: "test/a.js",
},
{
name: "corrupt token",
token: azTokenPrefix + "!!!not base64!!!",
prefix: "test/",
wantKey: azTokenPrefix + "!!!not base64!!!",
},
{
name: "empty token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
marker, key := decodeAzMarkerToken(tt.token, tt.prefix, tt.delimiter)
if marker != tt.wantMarker {
t.Errorf("azure marker: got %q, want %q", marker, tt.wantMarker)
}
if key != tt.wantKey {
t.Errorf("last key: got %q, want %q", key, tt.wantKey)
}
})
}
}
func TestListBlobsPagination(t *testing.T) {
keys := make([]string, 0, 2500)
for i := range 2500 {
keys = append(keys, fmt.Sprintf("test/asset-%04d.js", i))
}
client, srv := fakeAzureContainer(t, keys, 1000)
var got []string
var marker, azureMarker string
for page := 0; ; page++ {
if page > 10 {
t.Fatal("listing did not terminate")
}
res, err := (&Azure{}).listBlobs(context.Background(), client, azListingOpts{
prefix: "test/",
marker: marker,
azureMarker: azureMarker,
maxKeys: 1000,
})
if err != nil {
t.Fatalf("list blobs: %v", err)
}
for _, o := range res.objects {
got = append(got, backend.GetStringFromPtr(o.Key))
}
if !res.isTruncated {
break
}
if len(res.objects) != 1000 {
t.Fatalf("truncated page holds %v objects, want 1000", len(res.objects))
}
marker, azureMarker = res.lastKey, res.resumeMarker
}
if len(got) != len(keys) {
t.Fatalf("listed %v objects, want %v", len(got), len(keys))
}
for i, key := range keys {
if got[i] != key {
t.Fatalf("object %v: got %q, want %q", i, got[i], key)
}
}
// resuming from an Azure marker must keep the number of blob listings
// linear in the number of objects
if srv.requests > 5 {
t.Errorf("listing took %v azure requests, want at most 5", srv.requests)
}
}
func TestListBlobsMarkerAndDelimiter(t *testing.T) {
keys := []string{
"test/a/1.js",
"test/a/2.js",
"test/b/1.js",
"test/root.js",
"test/z/1.js",
}
client, _ := fakeAzureContainer(t, keys, 1000)
res, err := (&Azure{}).listBlobs(context.Background(), client, azListingOpts{
prefix: "test/",
delimiter: "/",
marker: "test/a/",
maxKeys: 1000,
})
if err != nil {
t.Fatalf("list blobs: %v", err)
}
var cps []string
for _, cp := range res.commonPrefixes {
cps = append(cps, backend.GetStringFromPtr(cp.Prefix))
}
if strings.Join(cps, ",") != "test/b/,test/z/" {
t.Errorf("common prefixes: got %v, want [test/b/ test/z/]", cps)
}
if len(res.objects) != 1 || backend.GetStringFromPtr(res.objects[0].Key) != "test/root.js" {
t.Errorf("objects: got %v, want [test/root.js]", res.objects)
}
if res.isTruncated {
t.Error("listing reported as truncated")
}
}
func TestListBlobsTruncatedCommonPrefixes(t *testing.T) {
// common prefixes deliberately span Azure page boundaries so paging must
// resume from an Azure marker and dedupe prefixes both within a page and
// across the truncation boundary
keys := []string{
"test/a/1.js",
"test/a/2.js",
"test/b/1.js",
"test/c/1.js",
"test/d/1.js",
}
client, _ := fakeAzureContainer(t, keys, 2)
var got []string
var marker, azureMarker string
for page := 0; ; page++ {
if page > 10 {
t.Fatal("listing did not terminate")
}
res, err := (&Azure{}).listBlobs(context.Background(), client, azListingOpts{
prefix: "test/",
delimiter: "/",
marker: marker,
azureMarker: azureMarker,
maxKeys: 2,
})
if err != nil {
t.Fatalf("list blobs: %v", err)
}
if len(res.objects) != 0 {
t.Fatalf("expected no objects, got %v", res.objects)
}
for _, cp := range res.commonPrefixes {
got = append(got, backend.GetStringFromPtr(cp.Prefix))
}
if !res.isTruncated {
break
}
marker, azureMarker = res.lastKey, res.resumeMarker
}
want := []string{"test/a/", "test/b/", "test/c/", "test/d/"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("common prefixes: got %v, want %v", got, want)
}
}
func TestListBlobsSkipsMultipartUploads(t *testing.T) {
// multipart staging blobs must be filtered out before they count against
// maxKeys, otherwise the listing would truncate early and hide real objects
keys := []string{
string(metaTmpMultipartPrefix) + "/upload-1/part",
string(metaTmpMultipartPrefix) + "/upload-2/part",
"a.js",
"b.js",
}
client, _ := fakeAzureContainer(t, keys, 1000)
res, err := (&Azure{}).listBlobs(context.Background(), client, azListingOpts{
maxKeys: 2,
})
if err != nil {
t.Fatalf("list blobs: %v", err)
}
var got []string
for _, o := range res.objects {
got = append(got, backend.GetStringFromPtr(o.Key))
}
if strings.Join(got, ",") != "a.js,b.js" {
t.Errorf("objects: got %v, want [a.js b.js]", got)
}
if res.isTruncated {
t.Error("listing reported as truncated; multipart blobs counted against maxKeys")
}
}
type fakeAzureServer struct {
requests int
}
// fakeAzureContainer serves blob listings the way Azure does: paged with opaque
// markers that only it mints, rejecting anything else with the same error real
// Azure returns for an S3 key passed as marker.
func fakeAzureContainer(t *testing.T, keys []string, pageSize int) (*container.Client, *fakeAzureServer) {
t.Helper()
state := &fakeAzureServer{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state.requests++
q := r.URL.Query()
prefix := q.Get("prefix")
start := 0
if marker := q.Get("marker"); marker != "" {
idx, err := strconv.Atoi(strings.TrimPrefix(marker, "azmarker-"))
if !strings.HasPrefix(marker, "azmarker-") || err != nil {
w.Header().Set("x-ms-error-code", "InvalidQueryParameterValue")
w.WriteHeader(http.StatusBadRequest)
return
}
start = idx
}
var matched []string
for _, key := range keys {
if strings.HasPrefix(key, prefix) {
matched = append(matched, key)
}
}
end := min(start+pageSize, len(matched))
var nextMarker string
if end < len(matched) {
nextMarker = fmt.Sprintf("azmarker-%d", end)
}
var body strings.Builder
body.WriteString(`<?xml version="1.0" encoding="utf-8"?><EnumerationResults><Blobs>`)
for _, key := range matched[start:end] {
fmt.Fprintf(&body, `<Blob><Name>%s</Name><Properties>`+
`<Last-Modified>Mon, 02 Jan 2006 15:04:05 GMT</Last-Modified>`+
`<Etag>0x8DEADBEEF</Etag><Content-Length>7</Content-Length>`+
`</Properties></Blob>`, key)
}
fmt.Fprintf(&body, `</Blobs><NextMarker>%s</NextMarker></EnumerationResults>`, nextMarker)
w.Header().Set("Content-Type", "application/xml")
//nolint:errcheck
w.Write([]byte(body.String()))
}))
t.Cleanup(srv.Close)
client, err := container.NewClientWithNoCredential(srv.URL+"/testbucket", nil)
if err != nil {
t.Fatalf("init container client: %v", err)
}
return client, state
}