Repository: nspcc-dev/neofs-s3-gw
Pull request #1177: s3 pagination failures
Author: login: smallhive
Created: 2025-05-26T10:59:33Z | Merged: 2025-05-30T10:29:01Z

Closes #1175

The tests themselves need to be updated as well

Linked issue #1175: s3 pagination failures
https://github.com/nspcc-dev/neofs-testcases/pull/1066 - tests are here

1. using cli (test_s3_pagination_in_objects_versions_listing_via_cli) - ```aws --no-verify-ssl  --profile profile__FoMEOq5RS9 s3api list-object-versions --bucket 4823586c-cbcb-45f8-9a2b-21a4e0ea7457 --endpoint https://localhost:50071 --page-size 5```
hangs infinitely, but should return first 5 elements
2. using boto3 (test_s3_pagination_in_objects_versions_listing_via_boto3):
```
            paginator = self.s3_client.get_paginator("list_object_versions")
            pages = paginator.paginate(Bucket=bucket, PaginationConfig={"PageSize": 5, "MaxItems": objects_count})
            results = []
>           for page in pages:
```
fails with:
```
E                   botocore.exceptions.PaginationError: Error during pagination: The same next token was received twice: {'KeyMarker': 'temp_file_453b5a6b-dda3-45ef-bdd0-815c2004038b', 'VersionIdMarker': 'DF99d361CQXNa8PBCwurpYuv36MGMe1ATJXGVjgtYCUo'}
```

Diff:
diff --git a/api/handler/object_list.go b/api/handler/object_list.go
index 1dd1c8bb..51d3f5a9 100644
--- a/api/handler/object_list.go
+++ b/api/handler/object_list.go
@@ -271,7 +271,7 @@ func parseListObjectVersionsRequest(reqInfo *api.ReqInfo) (*layer.ListObjectVers
 	}
 
 	res.Prefix = queryValues.Get("prefix")
-	res.KeyMarker = queryValues.Get("marker")
+	res.KeyMarker = queryValues.Get("key-marker")
 	res.Delimiter = queryValues.Get("delimiter")
 	res.Encode = queryValues.Get("encoding-type")
 	res.VersionIDMarker = queryValues.Get("version-id-marker")
diff --git a/api/layer/object.go b/api/layer/object.go
index 57280233..cd87f93a 100644
--- a/api/layer/object.go
+++ b/api/layer/object.go
@@ -408,8 +408,14 @@ func (n *layer) prepareMultipartHeadObject(ctx context.Context, p *PutObjectPara
 //
 // Returns ErrNodeNotFound if zero objects found.
 func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketInfo, owner user.ID, objectName string, onlyUnversioned bool) ([]allVersionsSearchResult, error) {
+	searchResults, _, err := n.searchAllVersionsInNeoFSWithCursor(ctx, bkt, owner, objectName, onlyUnversioned, nil)
+
+	return searchResults, err
+}
+
+func (n *layer) searchAllVersionsInNeoFSWithCursor(ctx context.Context, bkt *data.BucketInfo, owner user.ID, objectName string, onlyUnversioned bool, cursor *string) ([]allVersionsSearchResult, string, error) {
 	var (
-		filters             = make(object.SearchFilters, 0, 6)
+		filters             = make(object.SearchFilters, 0, 4)
 		returningAttributes = []string{
 			object.AttributeFilePath,
 			object.AttributeTimestamp,
@@ -418,7 +424,10 @@ func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketIn
 			s3headers.AttributeDeleteMarker,
 		}
 
-		opts client.SearchObjectsOptions
+		opts              client.SearchObjectsOptions
+		searchResultItems []client.SearchResultItem
+		nextCursor        string
+		err               error
 	)
 
 	if bt := bearerTokenFromContext(ctx, owner); bt != nil {
@@ -438,17 +447,28 @@ func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketIn
 		filters.AddFilter(s3headers.AttributeVersioningState, "", object.MatchNotPresent)
 	}
 
-	searchResultItems, err := n.neoFS.SearchObjectsV2(ctx, bkt.CID, filters, returningAttributes, opts)
-	if err != nil {
-		if errors.Is(err, apistatus.ErrObjectAccessDenied) {
-			return nil, s3errors.GetAPIError(s3errors.ErrAccessDenied)
+	if cursor != nil {
+		searchResultItems, nextCursor, err = n.neoFS.SearchObjectsV2WithCursor(ctx, bkt.CID, filters, returningAttributes, *cursor, opts)
+		if err != nil {
+			if errors.Is(err, apistatus.ErrObjectAccessDenied) {
+				return nil, "", s3errors.GetAPIError(s3errors.ErrAccessDenied)
+			}
+
+			return nil, "", fmt.Errorf("search object version: %w", err)
 		}
+	} else {
+		searchResultItems, err = n.neoFS.SearchObjectsV2(ctx, bkt.CID, filters, returningAttributes, opts)
+		if err != nil {
+			if errors.Is(err, apistatus.ErrObjectAccessDenied) {
+				return nil, "", s3errors.GetAPIError(s3errors.ErrAccessDenied)
+			}
 
-		return nil, fmt.Errorf("search object version: %w", err)
+			return nil, "", fmt.Errorf("search object version: %w", err)
+		}
 	}
 
 	if len(searchResultItems) == 0 {
-		return nil, ErrNodeNotFound
+		return nil, "", ErrNodeNotFound
 	}
 
 	var searchResults = make([]allVersionsSearchResult, 0, len(searchResultItems))
@@ -462,7 +482,7 @@ func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketIn
 		if item.Attributes[1] != "" {
 			psr.CreationTimestamp, err = strconv.ParseInt(item.Attributes[1], 10, 64)
 			if err != nil {
-				return nil, fmt.Errorf("invalid creation timestamp %s: %w", item.Attributes[1], err)
+				return nil, "", fmt.Errorf("invalid creation timestamp %s: %w", item.Attributes[1], err)
 			}
 		}
 
@@ -471,7 +491,7 @@ func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketIn
 		if item.Attributes[3] != "" {
 			psr.PayloadSize, err = strconv.ParseInt(item.Attributes[3], 10, 64)
 			if err != nil {
-				return nil, fmt.Errorf("invalid payload size %s: %w", item.Attributes[3], err)
+				return nil, "", fmt.Errorf("invalid payload size %s: %w", item.Attributes[3], err)
 			}
 		}
 
@@ -481,6 +501,10 @@ func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketIn
 	}
 
 	sortFunc := func(a, b allVersionsSearchResult) int {
+		if c := cmp.Compare(a.FilePath, b.FilePath); c != 0 { // direct order.
+			return c
+		}
+
 		if c := cmp.Compare(b.CreationTimestamp, a.CreationTimestamp); c != 0 { // reverse order.
 			return c
 		}
@@ -491,7 +515,7 @@ func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketIn
 
 	slices.SortFunc(searchResults, sortFunc)
 
-	return searchResults, nil
+	return searchResults, nextCursor, nil
 }
 
 func (n *layer) comprehensiveSearchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketInfo, owner user.ID, objectName string, onlyUnversioned bool) ([]allVersionsSearchResult, oid.ID, *data.LockInfo, error) {
@@ -1033,6 +1057,10 @@ func generateContinuationToken(filePath string) string {
 		id[i] = 255
 	}
 
+	return generateAdjustedContinuationToken(filePath, id)
+}
+
+func generateAdjustedContinuationToken(filePath string, id oid.ID) string {
 	cursorBuf := bytes.NewBuffer(nil)
 	cursorBuf.Write([]byte(object.AttributeFilePath))
 	cursorBuf.WriteByte(0x00)
@@ -1153,10 +1181,10 @@ func (n *layer) getLatestObjectsVersions(ctx context.Context, p allObjectParams)
 	return
 }
 
-func (n *layer) getAllObjectsVersions(ctx context.Context, bkt *data.BucketInfo, prefix, delimiter string) (map[string][]*data.ExtendedObjectInfo, error) {
-	searchResults, err := n.searchAllVersionsInNeoFS(ctx, bkt, bkt.Owner, prefix, false)
+func (n *layer) getAllObjectsVersions(ctx context.Context, bkt *data.BucketInfo, prefix, cursor, delimiter string) (map[string][]*data.ExtendedObjectInfo, string, error) {
+	searchResults, nextCursor, err := n.searchAllVersionsInNeoFSWithCursor(ctx, bkt, bkt.Owner, prefix, false, &cursor)
 	if err != nil {
-		return nil, err
+		return nil, "", err
 	}
 
 	versions := make(map[string][]*data.ExtendedObjectInfo, len(searchResults))
@@ -1205,7 +1233,7 @@ func (n *layer) getAllObjectsVersions(ctx context.Context, bkt *data.BucketInfo,
 		versions[oi.Name] = objVersions
 	}
 
-	return versions, nil
+	return versions, nextCursor, nil
 }
 
 func IsSystemHeader(key string) bool {
diff --git a/api/layer/versioning.go b/api/layer/versioning.go
index 02b83e41..fecf2960 100644
--- a/api/layer/versioning.go
+++ b/api/layer/versioning.go
@@ -1,59 +1,109 @@
 package layer
 
 import (
-	"cmp"
+	"bytes"
 	"context"
 	"errors"
+	"fmt"
+	"maps"
 	"slices"
+	"strconv"
+	"strings"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
-		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
-		res        = &ListObjectVersionsInfo{}
+		allObjects      = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
+		res             = &ListObjectVersionsInfo{}
+		cursor          string
+		id              oid.ID
+		keyMarkerLastTs uint64
+		err             error
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			parts := strings.Split(p.VersionIDMarker, ":")
+			if err = id.DecodeString(parts[0]); err != nil {
+				return nil, err
+			}
+
+			if len(parts) == 2 {
+				f, err := strconv.ParseInt(parts[1], 10, 64)
+				if err != nil {
+					return nil, err
+				}
+
+				keyMarkerLastTs = uint64(f)
+			}
 		}
-		return nil, err
-	}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+		cursor = generateAdjustedContinuationToken(p.KeyMarker, oid.ID{})
 	}
-	slices.Sort(sortedNames)
 
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
-
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := slices.Collect(maps.Keys(versions))
+		slices.Sort(sortedNames)
+
+		for _, name := range sortedNames {
+			sortedVersions := versions[name]
+			// The object with "null" version should be only one. We get only last (actual) one.
+			var isNullVersionCounted bool
+			var isLatestShouldBeSet = true
+
+			for i, version := range sortedVersions {
+				version.IsLatest = i == 0
+				if version.NodeVersion.IsUnversioned && isNullVersionCounted {
+					continue
+				}
+
+				if version.NodeVersion.IsUnversioned {
+					isNullVersionCounted = true
+				}
+
+				// On the next pages, we should filter out objects we already showed on the previous page.
+				if p.KeyMarker == version.NodeVersion.FilePath && keyMarkerLastTs > 0 {
+					// Objects are sorted in reverse order. The most recently stored objects are at the beginning.
+					// We should skip what has already been shown by time.
+					if keyMarkerLastTs < version.NodeVersion.Timestamp {
+						continue
+					}
+
+					// But sometimes the objects with the same name can be created in one second.
+					// To handle this situation, we have to filter out processed objects with OIDs.
+					if keyMarkerLastTs == version.NodeVersion.Timestamp && !id.IsZero() {
+						if bytes.Compare(id[:], version.NodeVersion.OID[:]) < 0 {
+							continue
+						}
+					}
+				}
+
+				if !version.IsLatest && isLatestShouldBeSet {
+					version.IsLatest = true
+				}
+
+				isLatestShouldBeSet = false
+
+				allObjects = append(allObjects, version)
 			}
-			allObjects = append(allObjects, version)
 		}
-	}
 
-	for i, obj := range allObjects {
-		if obj.ObjectInfo.Name >= p.KeyMarker && obj.ObjectInfo.VersionID() >= p.VersionIDMarker {
-			allObjects = allObjects[i:]
+		if nextCursor == "" || len(allObjects) >= p.MaxKeys {
 			break
 		}
 	}
@@ -62,8 +112,10 @@ func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsPar
 
 	if len(allObjects) > p.MaxKeys {
 		res.IsTruncated = true
-		res.NextKeyMarker = allObjects[p.MaxKeys].ObjectInfo.Name
-		res.NextVersionIDMarker = allObjects[p.MaxKeys].ObjectInfo.VersionID()
+		oi := allObjects[p.MaxKeys].ObjectInfo
+
+		res.NextKeyMarker = oi.Name
+		res.NextVersionIDMarker = fmt.Sprintf("%s:%d", oi.VersionID(), oi.Created.Unix())
 
 		allObjects = allObjects[:p.MaxKeys]
 		res.KeyMarker = allObjects[p.MaxKeys-1].ObjectInfo.Name
diff --git a/docs/aws_s3_compat.md b/docs/aws_s3_compat.md
index f0234235..ac6b7f59 100644
--- a/docs/aws_s3_compat.md
+++ b/docs/aws_s3_compat.md
@@ -167,6 +167,8 @@ See also `GetObject` and other method parameters.
 | 🟢 | ListObjectVersions | ListBucketObjectVersions |
 | 🔵 | RestoreObject      | Related to lifecycles and tiering. |
 
+In some cases `ListObjectVersions` may return fewer elements than `max-keys` requested.
+
 ## Bucket
 
 |    | Method               | Comments  |


Reviews:
user: login: roman-khimov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T20:03:23Z
user: login: smallhive
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T06:56:10Z
user: login: smallhive
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T06:56:21Z
user: login: smallhive
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T06:58:36Z
user: login: roman-khimov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-29T11:42:27Z
user: login: smallhive
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T09:09:05Z
user: login: smallhive
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T09:09:35Z
user: login: smallhive
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T09:12:56Z
user: login: roman-khimov
body: Probably OK, we need more tests.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-30T10:06:09Z
user: login: smallhive
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T10:10:32Z
user: login: smallhive
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T10:10:54Z
user: login: smallhive
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T10:11:02Z
user: login: smallhive
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T10:11:06Z
user: login: roman-khimov
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-30T10:28:36Z

Review comments:
diff_hunk: @@ -1488,3 +1499,85 @@ func (n *layer) searchEverythingForRemove(ctx context.Context, bkt *data.BucketI
 
 	return oids, nil
 }
+
+func (n *layer) searchAllVersionsInNeoFSWithCursor(ctx context.Context, bkt *data.BucketInfo, owner user.ID, objectName, cursor string) ([]allVersionsSearchResult, string, error) {
path: api/layer/object.go
user: login: roman-khimov
body: It duplicates a lot of `searchAllVersionsInNeoFS()`, DRY. Either `searchAllVersionsInNeoFS()` can reuse this one or maybe there is some other way to share.
created_at: 2025-05-28T19:57:50Z
author_association: MEMBER
diff_hunk: @@ -62,12 +86,25 @@ func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsPar
 
 	if len(allObjects) > p.MaxKeys {
 		res.IsTruncated = true
-		res.NextKeyMarker = allObjects[p.MaxKeys].ObjectInfo.Name
-		res.NextVersionIDMarker = allObjects[p.MaxKeys].ObjectInfo.VersionID()
+		res.NextKeyMarker = allObjects[p.MaxKeys-1].ObjectInfo.Name
+		res.NextVersionIDMarker = allObjects[p.MaxKeys-1].ObjectInfo.VersionID()
 
 		allObjects = allObjects[:p.MaxKeys]
-		res.KeyMarker = allObjects[p.MaxKeys-1].ObjectInfo.Name
-		res.VersionIDMarker = allObjects[p.MaxKeys-1].ObjectInfo.VersionID()
+
+		lastIndex := len(allObjects) - 1
+
+		if len(allObjects) >= 2 {
+			if allObjects[lastIndex].ObjectInfo.Name != allObjects[lastIndex-1].ObjectInfo.Name {
path: api/layer/versioning.go
user: login: roman-khimov
body: What if I have
```
a version 1
a version 2
b version 1
b version 2
.... next page
b version 3
```
?
created_at: 2025-05-28T20:01:03Z
author_association: MEMBER
diff_hunk: @@ -1,59 +1,83 @@
 package layer
 
 import (
+	"bytes"
 	"cmp"
 	"context"
 	"errors"
 	"slices"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
 		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
 		res        = &ListObjectVersionsInfo{}
+		cursor     string
+		id         oid.ID
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
-		}
-		return nil, err
-	}
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			if err := id.DecodeString(p.VersionIDMarker); err != nil {
+				return nil, err
+			}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+			cursor = generateAdjustedContinuationToken(p.KeyMarker, id)
+		} else {
+			// Or just skip all key versions and start from the next key.
+			cursor = generateContinuationToken(p.KeyMarker)
+		}
 	}
-	slices.Sort(sortedNames)
-
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := make([]string, 0, len(versions))
+		for k := range versions {
+			sortedNames = append(sortedNames, k)
+		}
+		slices.Sort(sortedNames)
+
+		for _, name := range sortedNames {
+			sortedVersions := versions[name]
+			slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
path: api/layer/versioning.go
user: login: roman-khimov
body: I've seen this somewhere already.
created_at: 2025-05-28T20:01:26Z
author_association: MEMBER
diff_hunk: @@ -1,59 +1,83 @@
 package layer
 
 import (
+	"bytes"
 	"cmp"
 	"context"
 	"errors"
 	"slices"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
 		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
 		res        = &ListObjectVersionsInfo{}
+		cursor     string
+		id         oid.ID
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
-		}
-		return nil, err
-	}
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			if err := id.DecodeString(p.VersionIDMarker); err != nil {
+				return nil, err
+			}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+			cursor = generateAdjustedContinuationToken(p.KeyMarker, id)
+		} else {
+			// Or just skip all key versions and start from the next key.
+			cursor = generateContinuationToken(p.KeyMarker)
+		}
 	}
-	slices.Sort(sortedNames)
-
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := make([]string, 0, len(versions))
+		for k := range versions {
path: api/layer/versioning.go
user: login: roman-khimov
body: `slices.Collect(maps.Keys(versions))`?
created_at: 2025-05-28T20:03:02Z
author_association: MEMBER
diff_hunk: @@ -1488,3 +1499,85 @@ func (n *layer) searchEverythingForRemove(ctx context.Context, bkt *data.BucketI
 
 	return oids, nil
 }
+
+func (n *layer) searchAllVersionsInNeoFSWithCursor(ctx context.Context, bkt *data.BucketInfo, owner user.ID, objectName, cursor string) ([]allVersionsSearchResult, string, error) {
path: api/layer/object.go
user: login: smallhive
body: Updated
created_at: 2025-05-29T06:56:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,59 +1,83 @@
 package layer
 
 import (
+	"bytes"
 	"cmp"
 	"context"
 	"errors"
 	"slices"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
 		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
 		res        = &ListObjectVersionsInfo{}
+		cursor     string
+		id         oid.ID
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
-		}
-		return nil, err
-	}
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			if err := id.DecodeString(p.VersionIDMarker); err != nil {
+				return nil, err
+			}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+			cursor = generateAdjustedContinuationToken(p.KeyMarker, id)
+		} else {
+			// Or just skip all key versions and start from the next key.
+			cursor = generateContinuationToken(p.KeyMarker)
+		}
 	}
-	slices.Sort(sortedNames)
-
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := make([]string, 0, len(versions))
+		for k := range versions {
path: api/layer/versioning.go
user: login: smallhive
body: It works as well
created_at: 2025-05-29T06:56:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,12 +86,25 @@ func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsPar
 
 	if len(allObjects) > p.MaxKeys {
 		res.IsTruncated = true
-		res.NextKeyMarker = allObjects[p.MaxKeys].ObjectInfo.Name
-		res.NextVersionIDMarker = allObjects[p.MaxKeys].ObjectInfo.VersionID()
+		res.NextKeyMarker = allObjects[p.MaxKeys-1].ObjectInfo.Name
+		res.NextVersionIDMarker = allObjects[p.MaxKeys-1].ObjectInfo.VersionID()
 
 		allObjects = allObjects[:p.MaxKeys]
-		res.KeyMarker = allObjects[p.MaxKeys-1].ObjectInfo.Name
-		res.VersionIDMarker = allObjects[p.MaxKeys-1].ObjectInfo.VersionID()
+
+		lastIndex := len(allObjects) - 1
+
+		if len(allObjects) >= 2 {
+			if allObjects[lastIndex].ObjectInfo.Name != allObjects[lastIndex-1].ObjectInfo.Name {
path: api/layer/versioning.go
user: login: smallhive
body: Exactly in your case, it will be ok. But unfortunately, ListObjectVersion works like:

```
a version 2
a version 1
b version 3
b version 2
.... next page
b version 1
```

Thus we have a problem, because we will return in the next page, because NeoFS search returns objects in direct order
```
b version 3
b version 2
```

This case requires further investigation
created_at: 2025-05-29T06:58:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -408,90 +408,9 @@ func (n *layer) prepareMultipartHeadObject(ctx context.Context, p *PutObjectPara
 //
 // Returns ErrNodeNotFound if zero objects found.
 func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketInfo, owner user.ID, objectName string, onlyUnversioned bool) ([]allVersionsSearchResult, error) {
-	var (
-		filters             = make(object.SearchFilters, 0, 6)
-		returningAttributes = []string{
-			object.AttributeFilePath,
-			object.AttributeTimestamp,
-			s3headers.AttributeVersioningState,
-			object.FilterPayloadSize,
-			s3headers.AttributeDeleteMarker,
-		}
-
-		opts client.SearchObjectsOptions
-	)
-
-	if bt := bearerTokenFromContext(ctx, owner); bt != nil {
-		opts.WithBearerToken(*bt)
-	}
-
-	if len(objectName) > 0 {
-		filters.AddFilter(object.AttributeFilePath, objectName, object.MatchStringEqual)
-	} else {
-		filters.AddFilter(object.AttributeFilePath, "", object.MatchCommonPrefix)
-	}
-
-	filters.AddTypeFilter(object.MatchStringEqual, object.TypeRegular)
-	filters.AddFilter(s3headers.MetaType, "", object.MatchNotPresent)
-
-	if onlyUnversioned {
-		filters.AddFilter(s3headers.AttributeVersioningState, "", object.MatchNotPresent)
-	}
-
-	searchResultItems, err := n.neoFS.SearchObjectsV2(ctx, bkt.CID, filters, returningAttributes, opts)
-	if err != nil {
-		if errors.Is(err, apistatus.ErrObjectAccessDenied) {
-			return nil, s3errors.GetAPIError(s3errors.ErrAccessDenied)
-		}
-
-		return nil, fmt.Errorf("search object version: %w", err)
-	}
-
-	if len(searchResultItems) == 0 {
-		return nil, ErrNodeNotFound
-	}
-
-	var searchResults = make([]allVersionsSearchResult, 0, len(searchResultItems))
-
-	for _, item := range searchResultItems {
-		var psr = allVersionsSearchResult{
-			ID:       item.ID,
-			FilePath: item.Attributes[0],
-		}
-
-		if item.Attributes[1] != "" {
-			psr.CreationTimestamp, err = strconv.ParseInt(item.Attributes[1], 10, 64)
-			if err != nil {
-				return nil, fmt.Errorf("invalid creation timestamp %s: %w", item.Attributes[1], err)
-			}
-		}
-
-		psr.IsVersioned = item.Attributes[2] == data.VersioningEnabled
-
-		if item.Attributes[3] != "" {
-			psr.PayloadSize, err = strconv.ParseInt(item.Attributes[3], 10, 64)
-			if err != nil {
-				return nil, fmt.Errorf("invalid payload size %s: %w", item.Attributes[3], err)
-			}
-		}
-
-		psr.IsDeleteMarker = item.Attributes[4] != ""
-
-		searchResults = append(searchResults, psr)
-	}
+	searchResults, _, err := n.searchAllVersionsInNeoFSWithCursor(ctx, bkt, owner, objectName, onlyUnversioned, nil)
path: api/layer/object.go
user: login: roman-khimov
body: `SearchObjectsV2()` was looping through all the results, this gets one page only. I think it's a regression.
created_at: 2025-05-29T11:39:40Z
author_association: MEMBER
diff_hunk: @@ -1488,3 +1418,103 @@ func (n *layer) searchEverythingForRemove(ctx context.Context, bkt *data.BucketI
 
 	return oids, nil
 }
+
+func (n *layer) searchAllVersionsInNeoFSWithCursor(ctx context.Context, bkt *data.BucketInfo, owner user.ID, objectName string, onlyUnversioned bool, cursor *string) ([]allVersionsSearchResult, string, error) {
path: api/layer/object.go
user: login: roman-khimov
body: It'd be easier to handle if this would be located nearby `searchAllVersionsInNeoFS`. The patch would be smaller.
created_at: 2025-05-29T11:40:37Z
author_association: MEMBER
diff_hunk: @@ -1,59 +1,83 @@
 package layer
 
 import (
+	"bytes"
 	"cmp"
 	"context"
 	"errors"
 	"slices"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
 		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
 		res        = &ListObjectVersionsInfo{}
+		cursor     string
+		id         oid.ID
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
-		}
-		return nil, err
-	}
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			if err := id.DecodeString(p.VersionIDMarker); err != nil {
+				return nil, err
+			}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+			cursor = generateAdjustedContinuationToken(p.KeyMarker, id)
+		} else {
+			// Or just skip all key versions and start from the next key.
+			cursor = generateContinuationToken(p.KeyMarker)
+		}
 	}
-	slices.Sort(sortedNames)
-
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := make([]string, 0, len(versions))
+		for k := range versions {
+			sortedNames = append(sortedNames, k)
+		}
+		slices.Sort(sortedNames)
+
+		for _, name := range sortedNames {
+			sortedVersions := versions[name]
+			slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
path: api/layer/versioning.go
user: login: roman-khimov
body: And that's `ListObjectVersions()`, this code can be shared.
created_at: 2025-05-29T11:41:48Z
author_association: MEMBER
diff_hunk: @@ -62,12 +86,25 @@ func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsPar
 
 	if len(allObjects) > p.MaxKeys {
 		res.IsTruncated = true
-		res.NextKeyMarker = allObjects[p.MaxKeys].ObjectInfo.Name
-		res.NextVersionIDMarker = allObjects[p.MaxKeys].ObjectInfo.VersionID()
+		res.NextKeyMarker = allObjects[p.MaxKeys-1].ObjectInfo.Name
+		res.NextVersionIDMarker = allObjects[p.MaxKeys-1].ObjectInfo.VersionID()
 
 		allObjects = allObjects[:p.MaxKeys]
-		res.KeyMarker = allObjects[p.MaxKeys-1].ObjectInfo.Name
-		res.VersionIDMarker = allObjects[p.MaxKeys-1].ObjectInfo.VersionID()
+
+		lastIndex := len(allObjects) - 1
+
+		if len(allObjects) >= 2 {
+			if allObjects[lastIndex].ObjectInfo.Name != allObjects[lastIndex-1].ObjectInfo.Name {
path: api/layer/versioning.go
user: login: roman-khimov
body: So, a separate issue?
created_at: 2025-05-29T11:42:24Z
author_association: MEMBER
diff_hunk: @@ -62,12 +86,25 @@ func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsPar
 
 	if len(allObjects) > p.MaxKeys {
 		res.IsTruncated = true
-		res.NextKeyMarker = allObjects[p.MaxKeys].ObjectInfo.Name
-		res.NextVersionIDMarker = allObjects[p.MaxKeys].ObjectInfo.VersionID()
+		res.NextKeyMarker = allObjects[p.MaxKeys-1].ObjectInfo.Name
+		res.NextVersionIDMarker = allObjects[p.MaxKeys-1].ObjectInfo.VersionID()
 
 		allObjects = allObjects[:p.MaxKeys]
-		res.KeyMarker = allObjects[p.MaxKeys-1].ObjectInfo.Name
-		res.VersionIDMarker = allObjects[p.MaxKeys-1].ObjectInfo.VersionID()
+
+		lastIndex := len(allObjects) - 1
+
+		if len(allObjects) >= 2 {
+			if allObjects[lastIndex].ObjectInfo.Name != allObjects[lastIndex-1].ObjectInfo.Name {
path: api/layer/versioning.go
user: login: smallhive
body: No, covered
created_at: 2025-05-30T09:09:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,59 +1,83 @@
 package layer
 
 import (
+	"bytes"
 	"cmp"
 	"context"
 	"errors"
 	"slices"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
 		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
 		res        = &ListObjectVersionsInfo{}
+		cursor     string
+		id         oid.ID
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
-		}
-		return nil, err
-	}
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			if err := id.DecodeString(p.VersionIDMarker); err != nil {
+				return nil, err
+			}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+			cursor = generateAdjustedContinuationToken(p.KeyMarker, id)
+		} else {
+			// Or just skip all key versions and start from the next key.
+			cursor = generateContinuationToken(p.KeyMarker)
+		}
 	}
-	slices.Sort(sortedNames)
-
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := make([]string, 0, len(versions))
+		for k := range versions {
+			sortedNames = append(sortedNames, k)
+		}
+		slices.Sort(sortedNames)
+
+		for _, name := range sortedNames {
+			sortedVersions := versions[name]
+			slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
path: api/layer/versioning.go
user: login: smallhive
body: Not required anymore
created_at: 2025-05-30T09:09:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -408,90 +408,9 @@ func (n *layer) prepareMultipartHeadObject(ctx context.Context, p *PutObjectPara
 //
 // Returns ErrNodeNotFound if zero objects found.
 func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketInfo, owner user.ID, objectName string, onlyUnversioned bool) ([]allVersionsSearchResult, error) {
-	var (
-		filters             = make(object.SearchFilters, 0, 6)
-		returningAttributes = []string{
-			object.AttributeFilePath,
-			object.AttributeTimestamp,
-			s3headers.AttributeVersioningState,
-			object.FilterPayloadSize,
-			s3headers.AttributeDeleteMarker,
-		}
-
-		opts client.SearchObjectsOptions
-	)
-
-	if bt := bearerTokenFromContext(ctx, owner); bt != nil {
-		opts.WithBearerToken(*bt)
-	}
-
-	if len(objectName) > 0 {
-		filters.AddFilter(object.AttributeFilePath, objectName, object.MatchStringEqual)
-	} else {
-		filters.AddFilter(object.AttributeFilePath, "", object.MatchCommonPrefix)
-	}
-
-	filters.AddTypeFilter(object.MatchStringEqual, object.TypeRegular)
-	filters.AddFilter(s3headers.MetaType, "", object.MatchNotPresent)
-
-	if onlyUnversioned {
-		filters.AddFilter(s3headers.AttributeVersioningState, "", object.MatchNotPresent)
-	}
-
-	searchResultItems, err := n.neoFS.SearchObjectsV2(ctx, bkt.CID, filters, returningAttributes, opts)
-	if err != nil {
-		if errors.Is(err, apistatus.ErrObjectAccessDenied) {
-			return nil, s3errors.GetAPIError(s3errors.ErrAccessDenied)
-		}
-
-		return nil, fmt.Errorf("search object version: %w", err)
-	}
-
-	if len(searchResultItems) == 0 {
-		return nil, ErrNodeNotFound
-	}
-
-	var searchResults = make([]allVersionsSearchResult, 0, len(searchResultItems))
-
-	for _, item := range searchResultItems {
-		var psr = allVersionsSearchResult{
-			ID:       item.ID,
-			FilePath: item.Attributes[0],
-		}
-
-		if item.Attributes[1] != "" {
-			psr.CreationTimestamp, err = strconv.ParseInt(item.Attributes[1], 10, 64)
-			if err != nil {
-				return nil, fmt.Errorf("invalid creation timestamp %s: %w", item.Attributes[1], err)
-			}
-		}
-
-		psr.IsVersioned = item.Attributes[2] == data.VersioningEnabled
-
-		if item.Attributes[3] != "" {
-			psr.PayloadSize, err = strconv.ParseInt(item.Attributes[3], 10, 64)
-			if err != nil {
-				return nil, fmt.Errorf("invalid payload size %s: %w", item.Attributes[3], err)
-			}
-		}
-
-		psr.IsDeleteMarker = item.Attributes[4] != ""
-
-		searchResults = append(searchResults, psr)
-	}
+	searchResults, _, err := n.searchAllVersionsInNeoFSWithCursor(ctx, bkt, owner, objectName, onlyUnversioned, nil)
path: api/layer/object.go
user: login: smallhive
body: Eventually, it is an optimisation. Now we took the minimum that we could. Before we did _paging_ in the code
created_at: 2025-05-30T09:12:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -481,17 +501,20 @@ func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketIn
 	}
 
 	sortFunc := func(a, b allVersionsSearchResult) int {
+		if c := cmp.Compare(a.FilePath, b.FilePath); c != 0 { // direct order.
+			return c
+		}
+
 		if c := cmp.Compare(b.CreationTimestamp, a.CreationTimestamp); c != 0 { // reverse order.
 			return c
 		}
 
 		// It is a temporary decision. We can't figure out what object was first and what the second right now.
 		return bytes.Compare(b.ID[:], a.ID[:]) // reverse order.
 	}
-
path: api/layer/object.go
user: login: roman-khimov
body: Not a useful change.
created_at: 2025-05-30T10:01:01Z
author_association: MEMBER
diff_hunk: @@ -1,59 +1,109 @@
 package layer
 
 import (
-	"cmp"
+	"bytes"
 	"context"
 	"errors"
+	"fmt"
+	"maps"
 	"slices"
+	"strconv"
+	"strings"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
-		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
-		res        = &ListObjectVersionsInfo{}
+		allObjects      = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
+		res             = &ListObjectVersionsInfo{}
+		cursor          string
+		id              oid.ID
+		keyMarkerLastTs uint64
+		err             error
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			parts := strings.Split(p.VersionIDMarker, ":")
+			if err = id.DecodeString(parts[0]); err != nil {
+				return nil, err
+			}
+
+			if len(parts) == 2 {
+				f, err := strconv.ParseInt(parts[1], 10, 64)
+				if err != nil {
+					return nil, err
+				}
+
+				keyMarkerLastTs = uint64(f)
+			}
 		}
-		return nil, err
-	}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+		cursor = generateAdjustedContinuationToken(p.KeyMarker, oid.ID{})
 	}
-	slices.Sort(sortedNames)
 
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
-
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := slices.Collect(maps.Keys(versions))
+		slices.Sort(sortedNames)
+
+		for _, name := range sortedNames {
+			sortedVersions := versions[name]
+			// The object with "null" version should be only one. We get only last (actual) one.
+			var isNullVersionCounted bool
+			var isLatestShoubBeSet = true
+
+			for i, version := range sortedVersions {
+				version.IsLatest = i == 0
+				if version.NodeVersion.IsUnversioned && isNullVersionCounted {
+					continue
+				}
+
+				if version.NodeVersion.IsUnversioned {
+					isNullVersionCounted = true
+				}
+
+				// On the next pages we should filter out objects we already showed on the previous page.
+				if p.KeyMarker == version.NodeVersion.FilePath && keyMarkerLastTs > 0 {
+					// Objects are sorder in reverse order. The most recently stored objects are in the beginning.
path: api/layer/versioning.go
user: login: roman-khimov
body: sorder?
created_at: 2025-05-30T10:03:05Z
author_association: MEMBER
diff_hunk: @@ -1,59 +1,109 @@
 package layer
 
 import (
-	"cmp"
+	"bytes"
 	"context"
 	"errors"
+	"fmt"
+	"maps"
 	"slices"
+	"strconv"
+	"strings"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
-		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
-		res        = &ListObjectVersionsInfo{}
+		allObjects      = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
+		res             = &ListObjectVersionsInfo{}
+		cursor          string
+		id              oid.ID
+		keyMarkerLastTs uint64
+		err             error
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			parts := strings.Split(p.VersionIDMarker, ":")
+			if err = id.DecodeString(parts[0]); err != nil {
+				return nil, err
+			}
+
+			if len(parts) == 2 {
+				f, err := strconv.ParseInt(parts[1], 10, 64)
+				if err != nil {
+					return nil, err
+				}
+
+				keyMarkerLastTs = uint64(f)
+			}
 		}
-		return nil, err
-	}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+		cursor = generateAdjustedContinuationToken(p.KeyMarker, oid.ID{})
 	}
-	slices.Sort(sortedNames)
 
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
-
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := slices.Collect(maps.Keys(versions))
+		slices.Sort(sortedNames)
+
+		for _, name := range sortedNames {
+			sortedVersions := versions[name]
+			// The object with "null" version should be only one. We get only last (actual) one.
+			var isNullVersionCounted bool
+			var isLatestShoubBeSet = true
+
+			for i, version := range sortedVersions {
+				version.IsLatest = i == 0
+				if version.NodeVersion.IsUnversioned && isNullVersionCounted {
+					continue
+				}
+
+				if version.NodeVersion.IsUnversioned {
+					isNullVersionCounted = true
+				}
+
+				// On the next pages we should filter out objects we already showed on the previous page.
+				if p.KeyMarker == version.NodeVersion.FilePath && keyMarkerLastTs > 0 {
+					// Objects are sorder in reverse order. The most recently stored objects are in the beginning.
+					// We should skip already showed by time.
+					if keyMarkerLastTs < version.NodeVersion.Timestamp {
+						continue
+					}
+
+					// But sometimes the objects with the same name can be created in the one secod. To handler this
path: api/layer/versioning.go
user: login: roman-khimov
body: secod?
created_at: 2025-05-30T10:03:24Z
author_association: MEMBER
diff_hunk: @@ -1,59 +1,109 @@
 package layer
 
 import (
-	"cmp"
+	"bytes"
 	"context"
 	"errors"
+	"fmt"
+	"maps"
 	"slices"
+	"strconv"
+	"strings"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
-		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
-		res        = &ListObjectVersionsInfo{}
+		allObjects      = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
+		res             = &ListObjectVersionsInfo{}
+		cursor          string
+		id              oid.ID
+		keyMarkerLastTs uint64
+		err             error
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			parts := strings.Split(p.VersionIDMarker, ":")
+			if err = id.DecodeString(parts[0]); err != nil {
+				return nil, err
+			}
+
+			if len(parts) == 2 {
+				f, err := strconv.ParseInt(parts[1], 10, 64)
+				if err != nil {
+					return nil, err
+				}
+
+				keyMarkerLastTs = uint64(f)
+			}
 		}
-		return nil, err
-	}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+		cursor = generateAdjustedContinuationToken(p.KeyMarker, oid.ID{})
 	}
-	slices.Sort(sortedNames)
 
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
-
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := slices.Collect(maps.Keys(versions))
+		slices.Sort(sortedNames)
+
+		for _, name := range sortedNames {
+			sortedVersions := versions[name]
+			// The object with "null" version should be only one. We get only last (actual) one.
+			var isNullVersionCounted bool
+			var isLatestShoubBeSet = true
path: api/layer/versioning.go
user: login: roman-khimov
body: Shoub?
created_at: 2025-05-30T10:03:50Z
author_association: MEMBER
diff_hunk: @@ -481,17 +501,20 @@ func (n *layer) searchAllVersionsInNeoFS(ctx context.Context, bkt *data.BucketIn
 	}
 
 	sortFunc := func(a, b allVersionsSearchResult) int {
+		if c := cmp.Compare(a.FilePath, b.FilePath); c != 0 { // direct order.
+			return c
+		}
+
 		if c := cmp.Compare(b.CreationTimestamp, a.CreationTimestamp); c != 0 { // reverse order.
 			return c
 		}
 
 		// It is a temporary decision. We can't figure out what object was first and what the second right now.
 		return bytes.Compare(b.ID[:], a.ID[:]) // reverse order.
 	}
-
path: api/layer/object.go
user: login: smallhive
body: Removed
created_at: 2025-05-30T10:10:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,59 +1,109 @@
 package layer
 
 import (
-	"cmp"
+	"bytes"
 	"context"
 	"errors"
+	"fmt"
+	"maps"
 	"slices"
+	"strconv"
+	"strings"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
-		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
-		res        = &ListObjectVersionsInfo{}
+		allObjects      = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
+		res             = &ListObjectVersionsInfo{}
+		cursor          string
+		id              oid.ID
+		keyMarkerLastTs uint64
+		err             error
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			parts := strings.Split(p.VersionIDMarker, ":")
+			if err = id.DecodeString(parts[0]); err != nil {
+				return nil, err
+			}
+
+			if len(parts) == 2 {
+				f, err := strconv.ParseInt(parts[1], 10, 64)
+				if err != nil {
+					return nil, err
+				}
+
+				keyMarkerLastTs = uint64(f)
+			}
 		}
-		return nil, err
-	}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+		cursor = generateAdjustedContinuationToken(p.KeyMarker, oid.ID{})
 	}
-	slices.Sort(sortedNames)
 
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
-
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := slices.Collect(maps.Keys(versions))
+		slices.Sort(sortedNames)
+
+		for _, name := range sortedNames {
+			sortedVersions := versions[name]
+			// The object with "null" version should be only one. We get only last (actual) one.
+			var isNullVersionCounted bool
+			var isLatestShoubBeSet = true
+
+			for i, version := range sortedVersions {
+				version.IsLatest = i == 0
+				if version.NodeVersion.IsUnversioned && isNullVersionCounted {
+					continue
+				}
+
+				if version.NodeVersion.IsUnversioned {
+					isNullVersionCounted = true
+				}
+
+				// On the next pages we should filter out objects we already showed on the previous page.
+				if p.KeyMarker == version.NodeVersion.FilePath && keyMarkerLastTs > 0 {
+					// Objects are sorder in reverse order. The most recently stored objects are in the beginning.
path: api/layer/versioning.go
user: login: smallhive
body: Fixed :disappointed: 
created_at: 2025-05-30T10:10:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,59 +1,109 @@
 package layer
 
 import (
-	"cmp"
+	"bytes"
 	"context"
 	"errors"
+	"fmt"
+	"maps"
 	"slices"
+	"strconv"
+	"strings"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
-		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
-		res        = &ListObjectVersionsInfo{}
+		allObjects      = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
+		res             = &ListObjectVersionsInfo{}
+		cursor          string
+		id              oid.ID
+		keyMarkerLastTs uint64
+		err             error
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			parts := strings.Split(p.VersionIDMarker, ":")
+			if err = id.DecodeString(parts[0]); err != nil {
+				return nil, err
+			}
+
+			if len(parts) == 2 {
+				f, err := strconv.ParseInt(parts[1], 10, 64)
+				if err != nil {
+					return nil, err
+				}
+
+				keyMarkerLastTs = uint64(f)
+			}
 		}
-		return nil, err
-	}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+		cursor = generateAdjustedContinuationToken(p.KeyMarker, oid.ID{})
 	}
-	slices.Sort(sortedNames)
 
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
-
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := slices.Collect(maps.Keys(versions))
+		slices.Sort(sortedNames)
+
+		for _, name := range sortedNames {
+			sortedVersions := versions[name]
+			// The object with "null" version should be only one. We get only last (actual) one.
+			var isNullVersionCounted bool
+			var isLatestShoubBeSet = true
+
+			for i, version := range sortedVersions {
+				version.IsLatest = i == 0
+				if version.NodeVersion.IsUnversioned && isNullVersionCounted {
+					continue
+				}
+
+				if version.NodeVersion.IsUnversioned {
+					isNullVersionCounted = true
+				}
+
+				// On the next pages we should filter out objects we already showed on the previous page.
+				if p.KeyMarker == version.NodeVersion.FilePath && keyMarkerLastTs > 0 {
+					// Objects are sorder in reverse order. The most recently stored objects are in the beginning.
+					// We should skip already showed by time.
+					if keyMarkerLastTs < version.NodeVersion.Timestamp {
+						continue
+					}
+
+					// But sometimes the objects with the same name can be created in the one secod. To handler this
path: api/layer/versioning.go
user: login: smallhive
body: Fixed
created_at: 2025-05-30T10:11:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,59 +1,109 @@
 package layer
 
 import (
-	"cmp"
+	"bytes"
 	"context"
 	"errors"
+	"fmt"
+	"maps"
 	"slices"
+	"strconv"
+	"strings"
 
 	"github.com/nspcc-dev/neofs-s3-gw/api/data"
+	oid "github.com/nspcc-dev/neofs-sdk-go/object/id"
 )
 
 func (n *layer) ListObjectVersions(ctx context.Context, p *ListObjectVersionsParams) (*ListObjectVersionsInfo, error) {
 	var (
-		allObjects = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
-		res        = &ListObjectVersionsInfo{}
+		allObjects      = make([]*data.ExtendedObjectInfo, 0, p.MaxKeys)
+		res             = &ListObjectVersionsInfo{}
+		cursor          string
+		id              oid.ID
+		keyMarkerLastTs uint64
+		err             error
 	)
 
-	versions, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, p.Delimiter)
-	if err != nil {
-		if errors.Is(err, ErrNodeNotFound) {
-			return res, nil
+	// We should start with specific key.
+	if p.KeyMarker != "" {
+		// We should start with specific key version.
+		if p.VersionIDMarker != "" {
+			parts := strings.Split(p.VersionIDMarker, ":")
+			if err = id.DecodeString(parts[0]); err != nil {
+				return nil, err
+			}
+
+			if len(parts) == 2 {
+				f, err := strconv.ParseInt(parts[1], 10, 64)
+				if err != nil {
+					return nil, err
+				}
+
+				keyMarkerLastTs = uint64(f)
+			}
 		}
-		return nil, err
-	}
 
-	sortedNames := make([]string, 0, len(versions))
-	for k := range versions {
-		sortedNames = append(sortedNames, k)
+		cursor = generateAdjustedContinuationToken(p.KeyMarker, oid.ID{})
 	}
-	slices.Sort(sortedNames)
 
-	for _, name := range sortedNames {
-		sortedVersions := versions[name]
-		slices.SortFunc(sortedVersions, func(a, b *data.ExtendedObjectInfo) int {
-			return cmp.Compare(b.NodeVersion.Timestamp, a.NodeVersion.Timestamp) // sort in reverse order
-		})
+	for {
+		versions, nextCursor, err := n.getAllObjectsVersions(ctx, p.BktInfo, p.Prefix, cursor, p.Delimiter)
+		cursor = nextCursor
 
-		// The object with "null" version should be only one. We get only last (actual) one.
-		var isNullVersionCounted bool
-
-		for i, version := range sortedVersions {
-			version.IsLatest = i == 0
-			if version.NodeVersion.IsUnversioned && isNullVersionCounted {
-				continue
+		if err != nil {
+			if errors.Is(err, ErrNodeNotFound) {
+				return res, nil
 			}
+			return nil, err
+		}
 
-			if version.NodeVersion.IsUnversioned {
-				isNullVersionCounted = true
+		sortedNames := slices.Collect(maps.Keys(versions))
+		slices.Sort(sortedNames)
+
+		for _, name := range sortedNames {
+			sortedVersions := versions[name]
+			// The object with "null" version should be only one. We get only last (actual) one.
+			var isNullVersionCounted bool
+			var isLatestShoubBeSet = true
path: api/layer/versioning.go
user: login: smallhive
body: Fixed
created_at: 2025-05-30T10:11:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: smallhive
created_at: 2025-05-26T10:59:56Z
body: **Change 1**

This subpart of the `test_s3_pagination_in_objects_versions_listing_via_boto3` fails each time, even without S3 gate communication

```python
...
        with allure.step("Check pagination with starting token"):
            paginator = self.s3_client.get_paginator("list_object_versions")
    
            next_token = None
            all_versions = []
    
            while True:
                pagination_args = {"Bucket": bucket, "PaginationConfig": {"PageSize": 5}}
                if next_token:
                    pagination_args["PaginationConfig"]["StartingToken"] = next_token
    
                page_iterator = paginator.paginate(**pagination_args)
>               page = next(page_iterator)
E               TypeError: 'PageIterator' object is not an iterator
```
with error
```
FAILED pytest_tests/tests/s3/test_s3_versioning.py::TestS3Versioning::test_s3_pagination_in_objects_versions_listing_via_boto3[boto3] - TypeError: 'PageIterator' object is not an iterator
```
author_association: CONTRIBUTOR
user: login: smallhive
created_at: 2025-05-26T11:01:03Z
body: **Change 2**

I consider that there is a logical error in the `test_s3_pagination_in_objects_versions_listing_via_boto3` test.

We took max_keys (5 in the original code) and save it into `versions`. In the next subtest, we check these 5 items with a fully collected bucket, as a result 20 != 5.

We should update the test in the required way. Either change `max_keys` or get versions one more time on the next test

```python
def test_s3_pagination_in_objects_versions_listing_via_boto3(self, prepare_versioned_objects: tuple):
        bucket, objects_count = prepare_versioned_objects
        with allure.step("Check basic pagination with MaxKeys"):
            max_keys = 5
```
to
```python
def test_s3_pagination_in_objects_versions_listing_via_boto3(self, prepare_versioned_objects: tuple):
        bucket, objects_count = prepare_versioned_objects
        with allure.step("Check basic pagination with MaxKeys"):
            max_keys = objects_count
```
or another way, if the original assumption was different

author_association: CONTRIBUTOR
user: login: smallhive
created_at: 2025-05-26T11:02:06Z
body: **Change 3**

inside `test_s3_pagination_in_objects_versions_listing_via_cli`
```
assert len(versions) == max_keys, f"Expected {max_keys} versions, got {len(versions)}"
```
replace with
```
assert len(versions) <= max_keys, f"Expected {max_keys} versions, got {len(versions)}"
```

The reason is [here](https://github.com/nspcc-dev/neofs-s3-gw/compare/1175-s3-pagination-failures?expand=1#diff-0dfb2a5ddffd63f0fa4b696362e4e869ddf4f8ab23757e05b51f00d6a0c8a6aaR98).
There are NeoFS search v2 cursor implementation-specific things against `ListObjectVersions` required behaviour
author_association: CONTRIBUTOR
user: login: evgeniiz321
created_at: 2025-05-26T18:40:23Z
body: @smallhive
**Change 1** - fixed
**Change 2** - there was just a comparison with a wrong variable there - fixed it as well
**Change 3** - fixed
But tests still fail on the original problem - `The same next token was received twice` from this PR as well
author_association: NONE
user: login: roman-khimov
created_at: 2025-05-27T10:00:00Z
body: @smallhive: all of the explanations belong to the _commit message_. That's exactly what it is for.
author_association: MEMBER
user: login: smallhive
created_at: 2025-05-30T09:13:17Z
body: Reworked
author_association: CONTRIBUTOR