Skip to content

[Listing] Resolve undecidable list entries against the locked read path - #221

Open
mrjavadseydi wants to merge 2 commits into
pgsty:mainfrom
mrjavadseydi:fix/issue-218-listobjects-rolling-restart
Open

mrjavadseydi wants to merge 2 commits into
pgsty:mainfrom
mrjavadseydi:fix/issue-218-listobjects-rolling-restart

Conversation

@mrjavadseydi

@mrjavadseydi mrjavadseydi commented Sep 19, 2026 •

Copy link
Copy Markdown

Closes #218

Contribution Licensing (no CLA, inbound=outbound, DCO required)

This pull request contributes to PGSTY SILO (pgsty/silo). Code contributions
are accepted under AGPL-3.0-or-later, the same license as the server.
This project does not use a CLA or require a separate Apache-2.0 license grant.
By submitting this pull request I represent that I have the right to contribute
the code changes under this repository's
GNU Affero General Public License v3.0 or later
and retain copyright in my original work. Existing copyright and license
notices remain intact; separately licensed material keeps its applicable terms.
Every commit must carry a DCO Signed-off-by trailer
(git commit -s) certifying the
Developer Certificate of Origin — see
CONTRIBUTING.md.

Description

A successful ListObjects response could omit a readable key when per-drive
walk snapshots disagreed during a rolling restart with concurrent overwrites.
metaCacheEntries.resolve returned no entry, and listPathRaw's partial
callback silently dropped it. For synchronization tools, a false absence is
more dangerous than a request-level error.

This revision contains two commits:

Commit Change
258b167b1 Resolve undecidable latest-only entries against the locked object read path
8c784f613 Narrow the fallback and add review regressions

For latest-only listings, resolveListEntry now falls back only when the entry
could still reach listing quorum: valid walk entries plus failed readers must
be at least objQuorum. Non-latest-only callers retain the previous behavior,
so stale minority metadata is ignored by Walk, version listings,
DeleteBucket, and IAM initialization.

The fallback:

  1. encodes directory-object names before locking and reading;
  2. takes the same object read lock used by GET, with a five-second wait bound;
  3. reads through getObjectFileInfo after acquiring the lock;
  4. returns a read-quorum error when that read reports not-found, because
    getObjectFileInfo also maps inconsistent metadata and empty merges to
    not-found;
  5. omits an entry only when the locked read returns a delete marker, and emits
    the resolved metadata when it returns a live object.

listPathRaw's partial callback gains an error return so an undecidable
latest-only entry can fail the request instead of disappearing from an HTTP
200 page.

Why the lock is load-bearing

An earlier draft performed the fallback read without the namespace lock. In
the rolling-restart investigation, an instrumented build probed each dropped
entry at the point of omission:

Probe Result
First lock-free getObjectFileInfo Object not found — 51/51
Immediate lock-free retry Object not found — 47/51
Same read under the object read lock succeeded — 51/51

A lock-free fallback therefore traded one silent omission for another.
TestListFallbackTakesObjectReadLock pins the lock behavior.

Scope after review

  • The cross-parity mergeXLV2Versions change was removed from both the diff
    and branch history. Cross-parity selection is a separate, pre-existing issue
    and should receive its own narrow fix and differential tests.
    TestListCrossParityMinorityStaysVisible protects the baseline drive-0 case
    from regression without expanding this PR into that fix.
  • Non-latest-only callers do not use the fallback.
  • WalkDir still tolerates a minority xl.meta read failure instead of
    failing the remaining stream. TestListObjectsSurvivesTruncatedMetadataMinority
    pins this behavior.

Motivation and context

Issue #218 reported readable keys missing from successful listings during a
4-node × 4-drive rolling restart with concurrent overwrites. Instrumentation
captured 25 omissions and tied all of them to merges that selected no
generation. The deterministic shape-A regression uses eight readable walk
streams split 3/5 across two generations while the other eight walk streams
fail. The locked fallback re-reads the stable object state instead of treating
that snapshot disagreement as absence.

How to test this PR

go test ./cmd/ -run 'Test(ListObjectsFallsBackToReadableMetadata|ListFallbackTakesObjectReadLock|ListObjectsSurvivesTruncatedMetadataMinority|ListStaleMinorityIgnored|IAMListingIgnoresStaleMinority|ListSplitDirectoryObject|ListFallbackDoesNotTrustNotFound|ListCrossParityMinorityStaysVisible)$' -count=1 -v
go test ./cmd/ -count=1

New regression coverage:

Test Pins
TestListObjectsFallsBackToReadableMetadata Split latest-only snapshots resolve through the stable read path
TestListFallbackTakesObjectReadLock The fallback waits for the object read lock
TestListObjectsSurvivesTruncatedMetadataMinority A truncated metadata minority does not fail or omit the listing
TestListStaleMinorityIgnored Latest, versioned, Walk, and DeleteBucket callers ignore a stale minority
TestIAMListingIgnoresStaleMinority IAM startup listing retains its pre-fix stale-minority behavior
TestListSplitDirectoryObject dir/ is locked and read as dir__XLDIR__
TestListFallbackDoesNotTrustNotFound Mapped not-found remains undecidable and returns read-quorum failure
TestListCrossParityMinorityStaysVisible Removing the rejected recount does not regress the baseline drive-0 cross-parity case

Manual reproduction evidence

The following measurements are historical evidence from the pre-review
revision at 510da3c7c
, which still included the now-removed cross-parity
commit. The external rolling-restart harness was not rerun for this narrowed
revision, so these rows must not be read as current-head benchmark results.

Build tested at the time LIST 200 Omitting lists Omitted keys Omitted keys readable
baseline (8d06424b1, no fix) 14,846 12 25 9 of 25
unlocked fallback (rejected) 16,638 22 29 29 of 29
pre-review 510da3c7c, run 1 17,876 0 0 —
pre-review 510da3c7c, run 2 16,140 0 0 —

The retained harness, instrumented builds, and raw logs remain outside the
repository as required by AGENTS.md.

Compatibility impact

Surface Impact
S3 API / wire format None. No request or response shape changes.
Configuration None. No keys or list-quorum semantics changed.
Storage format None. No xl.meta format change.
Upgrade / rollback No persisted state changes.
Internal API listPathRawOptions.partial gains an error return; existing non-listing callers return nil.
Behavior Only latest-only entries that could still reach quorum use the fallback. A still-undecidable fallback fails the request instead of returning HTTP 200 without the key.
Performance One locked metadata read per eligible unresolved entry, with a five-second lock-wait bound. Resolved entries and stale minorities do not pay this cost.

Best-effort upstream MinIO compatibility is preserved. The maintained and
tested target remains the coordinated PGSTY SILO stack.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature
  • Optimization
  • Breaking change

Checklist

  • Both commits carry DCO Signed-off-by trailers.
  • Eight focused regression tests cover the fallback and review findings.
  • go test ./cmd/ -count=1 passes on the narrowed revision.
  • No public API, configuration, metrics, or storage-format changes.
  • Update the companion-site operator workaround after this fix ships.

Verification notes

Verified locally on darwin/arm64, based on main at 2fde3cf53:

  • focused eight-test command above — pass;
  • go test ./cmd/ -count=1 — pass (330.026s);
  • git diff --check — clean;
  • both commits have DCO sign-off.

@Vonng

Vonng commented Sep 22, 2026

Copy link
Copy Markdown
Member

Thanks a lot @mrjavadseydi, this is exactly the investigation #218 needed. Tying all 25 omissions to a single exit, the deterministic shape A test, and showing that the fallback read has to take the lock are all really valuable.

We tested the PR against its base (2fde3cf) and found a few issues to sort out before merging:

1. Commit 2 fails non-latest-only callers on stale minority entries. When requestedVersions != 1, any unresolved entry now fails the request, and no in-tree Walk caller sets LatestOnly. The most common unresolved entry is a stale minority, such as xl.meta left on a node that was away during a delete, and main correctly drops it. With one deleted object left on 4 of 16 drives:

main #221
Walk(WalkOptions{}) works aborts with context canceled
ListObjectVersions 200 503
Non-force DeleteBucket works fails
IAM listAllIAMConfigItems works fails with a retriable error, so IAMSys.Init keeps retrying at startup until the entry is healed

2. Commit 1 can turn existing objects into 404s. mergeXLV2Versions also backs GET/HEAD, heal, the scanner and decommission. The new v < EcM gate only counts the streams that were read, so while drives are offline an existing object can fall below it. Example: one node away, 11 × G1 (12+4) and 1 × G0 (8+8) online. With G0 on drive 0, main answers HEAD with 503 and lists the key; #221 answers 404 and returns a 200 LIST without it, because the locked fallback reads through the same merge. With G0 on drive 11, main misses it too. That gap is real and pre-existing, and worth its own narrow fix on top of 8d06424. Commit 1 also doesn't change either captured shape: A has uniform parity, and in B no generation reaches quorum 8.

3. Two gaps in the fallback.

  • Not-found under the lock doesn't prove absence: getObjectFileInfo maps RQInconsistentMeta and empty merges to not-found, which is where the 404 above comes from.
  • Directory objects: listing entries carry the decoded name (dir/), while the object is stored and locked as dir__XLDIR__, so the fallback still misses them.

Suggestions

  1. Drop commit 1 from this PR. If you'd like to take on cross-parity separately, recount only when the original selection lacks quorum, without a data-block gate, and add a differential test against main.
  2. Narrow commit 2:
    • fall back only when requestedVersions == 1, and only when the entry could still reach quorum (valid entries + failed readers ≥ objQuorum); keep today's behavior everywhere else;
    • encode directory object names;
    • bound the lock wait, which is globalOperationTimeout today.
  3. Add the probes below as regression tests.

Thanks again for digging into this so thoroughly. Looking forward to the next revision!

Probes (put in cmd/; they reuse the fixtures in metacache-null-quorum_test.go)

go test ./cmd/ -run TestReview221 -v:

Test main 2fde3cf #221 510da3c
StaleMinorityIgnoredByListings PASS FAIL
StaleMinorityIgnoredByIAMListing PASS FAIL
CrossParityMinorityStaysVisible/g0-on-drive-0 PASS FAIL
CrossParityMinorityStaysVisible/g0-on-drive-11 FAIL FAIL
SplitDirectoryObjectListed FAIL FAIL
package cmd

// Review probes for pgsty/silo#221. They reuse the fixtures in
// metacache-null-quorum_test.go (nullQuorumBackend and friends).

import (
	"bytes"
	"context"
	"encoding/xml"
	"fmt"
	"io"
	"net/http"
	"slices"
	"testing"
	"time"
)

// reviewOfflineDisk models a drive of a node that is away.
type reviewOfflineDisk struct{ StorageAPI }

func (d *reviewOfflineDisk) IsOnline() bool { return false }

func (d *reviewOfflineDisk) DiskInfo(context.Context, DiskInfoOptions) (DiskInfo, error) {
	return DiskInfo{}, errDiskNotFound
}

func (d *reviewOfflineDisk) WalkDir(context.Context, WalkDirOptions, io.Writer) error {
	return errDiskNotFound
}

func (d *reviewOfflineDisk) ReadXL(context.Context, string, string, bool) (RawFileInfo, error) {
	return RawFileInfo{}, errDiskNotFound
}

func (d *reviewOfflineDisk) ReadVersion(context.Context, string, string, string, string, ReadOptions) (FileInfo, error) {
	return FileInfo{}, errDiskNotFound
}

// reviewFailWalkDisk fails only its walk stream, as in the PR's shape A test.
type reviewFailWalkDisk struct{ StorageAPI }

func (d *reviewFailWalkDisk) WalkDir(context.Context, WalkDirOptions, io.Writer) error {
	return errDiskNotFound
}

func reviewPut(t *testing.T, z *erasureServerPools, bucket, object string, body []byte, opts ObjectOptions) {
	t.Helper()
	if _, err := z.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), opts); err != nil {
		t.Fatal(err)
	}
}

func reviewWalk(t *testing.T, z *erasureServerPools, bucket string) (names []string, err error) {
	t.Helper()
	ch := make(chan itemOrErr[ObjectInfo], 100)
	if err := z.Walk(t.Context(), bucket, "", ch, WalkOptions{}); err != nil {
		return nil, err
	}
	for it := range ch {
		if it.Err != nil {
			err = it.Err
			continue
		}
		names = append(names, it.Item.Name)
	}
	return names, err
}

func reviewListV2(t *testing.T, router http.Handler, bucket string) (int, []string) {
	t.Helper()
	rec := nullQuorumRequest(t, router, http.MethodGet, getListObjectsV2URL("", bucket, "", "1000", "", "", ""))
	if rec.Code != http.StatusOK {
		return rec.Code, nil
	}
	var list ListObjectsV2Response
	if err := xml.Unmarshal(rec.Body.Bytes(), &list); err != nil {
		t.Fatal(err)
	}
	var keys []string
	for _, c := range list.Contents {
		keys = append(keys, c.Key)
	}
	return rec.Code, keys
}

func reviewListVersions(t *testing.T, router http.Handler, bucket string) (int, []string) {
	t.Helper()
	rec := nullQuorumRequest(t, router, http.MethodGet, getListObjectVersionsURL("", bucket, "", "1000", ""))
	if rec.Code != http.StatusOK {
		return rec.Code, nil
	}
	var list struct {
		Versions []struct {
			Key string `xml:"Key"`
		} `xml:"Version"`
	}
	if err := xml.Unmarshal(rec.Body.Bytes(), &list); err != nil {
		t.Fatal(err)
	}
	var keys []string
	for _, v := range list.Versions {
		keys = append(keys, v.Key)
	}
	return rec.Code, keys
}

// An object deleted while four drives were away keeps its xl.meta on those
// four drives until it is healed. main drops that minority entry everywhere.
func TestReview221StaleMinorityIgnoredByListings(t *testing.T) {
	z, bucket, router := nullQuorumBackend(t)
	disks := z.serverPools[0].sets[0].getDisks()
	body := bytes.Repeat([]byte{'a'}, 8192)
	reviewPut(t, z, bucket, "gone", body, ObjectOptions{})
	reviewPut(t, z, bucket, "keep", body, ObjectOptions{})
	for _, disk := range disks[4:] {
		if err := disk.Delete(t.Context(), bucket, "gone", DeleteOptions{Recursive: true, Immediate: true}); err != nil {
			t.Fatal(err)
		}
	}

	if names, err := reviewWalk(t, z, bucket); err != nil || !slices.Equal(names, []string{"keep"}) {
		t.Errorf("Walk: got %v, %v; want [keep], nil", names, err)
	}
	if code, keys := reviewListVersions(t, router, bucket); code != http.StatusOK || !slices.Equal(keys, []string{"keep"}) {
		t.Errorf("ListObjectVersions: got %d %v; want 200 [keep]", code, keys)
	}
	if _, err := z.DeleteObject(t.Context(), bucket, "keep", ObjectOptions{}); err != nil {
		t.Fatal(err)
	}
	if err := z.DeleteBucket(t.Context(), bucket, DeleteBucketOptions{}); err != nil {
		t.Errorf("DeleteBucket of a bucket holding only the stale minority: %v", err)
	}
}

// The IAM loader lists .minio.sys/config/iam through the same Walk.
func TestReview221StaleMinorityIgnoredByIAMListing(t *testing.T) {
	z, _, _ := nullQuorumBackend(t)
	body := []byte(`{"version":1}`)
	alive := iamConfigUsersPrefix + "alive/identity.json"
	ghost := iamConfigUsersPrefix + "ghost/identity.json"
	reviewPut(t, z, minioMetaBucket, alive, body, ObjectOptions{})
	reviewPut(t, z, minioMetaBucket, ghost, body, ObjectOptions{})
	for _, disk := range z.serverPools[0].getHashedSet(ghost).getDisks()[4:] {
		if err := disk.Delete(t.Context(), minioMetaBucket, ghost, DeleteOptions{Recursive: true, Immediate: true}); err != nil {
			t.Fatal(err)
		}
	}

	items, err := newIAMObjectStore(z, MinIOUsersSysType).listAllIAMConfigItems(t.Context())
	if err != nil {
		t.Fatalf("IAM listing failed on a stale minority (retriable=%v): %v", configRetriableErrors(err), err)
	}
	if got := items["users/"]; !slices.Equal(got, []string{"alive/identity.json"}) {
		t.Errorf("IAM users: got %v; want [alive/identity.json]", got)
	}
}

// G0 was written 8+8 while a node was away. G1 was later written 12+4 but
// missed one drive, which still holds G0. Then a node holding four G1 drives
// goes away: eleven G1 and one G0 remain online. G1 exists, but lacks one of
// its twelve data shards until the node returns, so HEAD should report 503
// rather than 404, and LIST should still show the key.
func TestReview221CrossParityMinorityStaysVisible(t *testing.T) {
	for _, g0Drive := range []int{0, 11} {
		t.Run(fmt.Sprintf("g0-on-drive-%d", g0Drive), func(t *testing.T) {
			z, bucket, router := nullQuorumBackend(t)
			set := z.serverPools[0].sets[0]
			disks := set.getDisks()
			const object = "object"
			mtime := time.Now().UTC().Add(-time.Hour)

			reviewPut(t, z, bucket, object, bytes.Repeat([]byte{'a'}, 256<<10), ObjectOptions{MTime: mtime, MaxParity: true})
			g0 := mustReadNullQuorumMeta(t, disks[g0Drive], bucket, object)
			var x xlMetaV2
			if err := x.LoadOrConvert(g0); err != nil {
				t.Fatal(err)
			}
			if h := x.versions[0].header; h.EcM != 8 || h.EcN != 8 {
				t.Fatalf("G0 is %d+%d, want 8+8", h.EcM, h.EcN)
			}
			g1Body := bytes.Repeat([]byte{'b'}, 256<<10)
			reviewPut(t, z, bucket, object, g1Body, ObjectOptions{MTime: mtime.Add(time.Minute)})
			if err := disks[g0Drive].WriteAll(t.Context(), bucket, object+"/"+xlStorageFormatFile, g0); err != nil {
				t.Fatal(err)
			}

			online := slices.Clone(disks)
			for i := 12; i < len(online); i++ {
				online[i] = &reviewOfflineDisk{StorageAPI: disks[i]}
			}
			set.getDisks = func() []StorageAPI { return online }
			if code := nullQuorumRequest(t, router, http.MethodHead, getHeadObjectURL("", bucket, object)).Code; code == http.StatusNotFound {
				t.Errorf("HEAD with a node away: 404 for an existing object")
			}
			if code, keys := reviewListV2(t, router, bucket); code != http.StatusOK || !slices.Equal(keys, []string{object}) {
				t.Errorf("ListObjectsV2 with a node away: got %d %v; want 200 [%s]", code, keys, object)
			}

			set.getDisks = func() []StorageAPI { return disks }
			if get := nullQuorumRequest(t, router, http.MethodGet, getGetObjectURL("", bucket, object)); get.Code != http.StatusOK || !bytes.Equal(get.Body.Bytes(), g1Body) {
				t.Fatalf("GET after the node returns: %d", get.Code)
			}
		})
	}
}

// The PR's shape A for a directory object. Listing entries carry the decoded
// name ("dir/"), while the object is stored and locked as "dir__XLDIR__".
func TestReview221SplitDirectoryObjectListed(t *testing.T) {
	z, bucket, router := nullQuorumBackend(t)
	set := z.serverPools[0].sets[0]
	disks := set.getDisks()
	const object = "dir/"
	enc := encodeDirObject(object)
	mtime := time.Now().UTC().Add(-time.Hour)

	reviewPut(t, z, bucket, object, nil, ObjectOptions{MTime: mtime})
	old := make([][]byte, 3)
	for i := range old {
		old[i] = mustReadNullQuorumMeta(t, disks[i], bucket, enc)
	}
	reviewPut(t, z, bucket, object, nil, ObjectOptions{MTime: mtime.Add(time.Minute)})
	for i := range old {
		if err := disks[i].WriteAll(t.Context(), bucket, enc+"/"+xlStorageFormatFile, old[i]); err != nil {
			t.Fatal(err)
		}
	}

	walkers := slices.Clone(disks)
	for i := 8; i < len(walkers); i++ {
		walkers[i] = &reviewFailWalkDisk{StorageAPI: disks[i]}
	}
	set.getDisks = func() []StorageAPI { return walkers }
	if code, keys := reviewListV2(t, router, bucket); code != http.StatusOK || !slices.Equal(keys, []string{object}) {
		t.Errorf("ListObjectsV2: got %d %v; want 200 [%s]", code, keys, object)
	}
}

@mrjavadseydi
mrjavadseydi force-pushed the fix/issue-218-listobjects-rolling-restart branch from 510da3c to 8c784f6 Compare September 23, 2026 15:34
@mrjavadseydi

Copy link
Copy Markdown
Author

Thanks for the detailed review. I’ve force pushed an updated revision addressing the feedback:

  • removed the cross-parity commit from the branch history;
  • limited the fallback to latest only entries that can still reach quorum;
  • preserved the existing stale minority behavior for Walk, version listings, DeleteBucket, and IAM;
  • encoded directory object names and bounded the lock wait;
  • treated mapped not found results as undecidable instead of proven absence;
  • added the requested regression coverage and updated the PR description.

All CI checks are green. The PR is ready for another review
thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] ListObjects can omit readable keys during rolling restarts with concurrent overwrites

2 participants