Skip to content

Commit 694e94a

Browse files
authored
feat(Storage): list buckets partial success (#8745)
1 parent 8169fa7 commit 694e94a

4 files changed

Lines changed: 186 additions & 16 deletions

File tree

Storage/src/BucketIterator.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Google Inc. All Rights Reserved.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
namespace Google\Cloud\Storage;
19+
20+
use ArrayObject;
21+
use Google\Cloud\Core\Iterator\ItemIterator;
22+
use Google\Cloud\Core\Iterator\PageIterator;
23+
24+
/**
25+
* Iterates over a set of buckets.
26+
*
27+
* Use the `unreachable()` method to get a list of bucket names that could not
28+
* be retrieved. This is only populated when the `returnPartialSuccess` is set to true
29+
*/
30+
class BucketIterator extends ItemIterator
31+
{
32+
/**
33+
* @param PageIterator $iterator
34+
* @param ArrayObject $unreachable
35+
*/
36+
public function __construct(PageIterator $iterator, private ArrayObject $unreachable)
37+
{
38+
parent::__construct($iterator);
39+
}
40+
41+
/**
42+
* Get the list of unreachable buckets.
43+
*
44+
* @return array<string>
45+
*/
46+
public function unreachable()
47+
{
48+
return $this->unreachable->getArrayCopy();
49+
}
50+
}

Storage/src/Connection/ServiceDefinition/storage-v1.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,13 @@
745745
"$ref": "Bucket"
746746
}
747747
},
748+
"unreachable": {
749+
"type": "array",
750+
"description": "The list of bucket resource names that could not be reached during the listing operation.",
751+
"items": {
752+
"type": "string"
753+
}
754+
},
748755
"kind": {
749756
"type": "string",
750757
"description": "The kind of item this is. For lists of buckets, this is always storage#buckets.",
@@ -2528,6 +2535,11 @@
25282535
"type": "boolean",
25292536
"description": "If set to true, only soft-deleted bucket versions are listed as distinct results in order of bucket name and generation number. The default value is false.",
25302537
"location": "query"
2538+
},
2539+
"returnPartialSuccess": {
2540+
"type": "boolean",
2541+
"description": "If true, returns a partial list of buckets. The `unreachable` field will contain buckets that were not reachable.",
2542+
"location": "query"
25312543
}
25322544
},
25332545
"parameterOrder": [

Storage/src/StorageClient.php

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -273,24 +273,37 @@ public function bucket($name, $userProject = false, array $options = [])
273273
* If false, `$options.userProject` will be used ONLY for the
274274
* listBuckets operation. If `$options.userProject` is not set,
275275
* this option has no effect. **Defaults to** `true`.
276+
* @type bool $returnPartialSuccess If true, the returned iterator will contain an
277+
* `unreachable` property with a list of buckets that were not retrieved.
278+
* **Note:** If set to false (default) and unreachable buckets are found,
279+
* the operation will throw an exception.
280+
*
276281
* }
277-
* @return ItemIterator<Bucket>
282+
* @return BucketIterator<Bucket>
278283
* @throws GoogleException When a project ID has not been detected.
279284
*/
280285
public function buckets(array $options = [])
281286
{
282287
$this->requireProjectId();
283-
284288
$resultLimit = $this->pluck('resultLimit', $options, false);
285-
$bucketUserProject = $this->pluck('bucketUserProject', $options, false);
286-
$bucketUserProject = !is_null($bucketUserProject)
287-
? $bucketUserProject
288-
: true;
289-
$userProject = (isset($options['userProject']) && $bucketUserProject)
290-
? $options['userProject']
291-
: null;
292-
293-
return new ItemIterator(
289+
$bucketUserProject = $this->pluck('bucketUserProject', $options, null) ?? true;
290+
$userProject = $bucketUserProject ? ($options['userProject'] ?? null) : null;
291+
292+
$unreachable = new \ArrayObject();
293+
294+
$apiCall = [$this->connection, 'listBuckets'];
295+
$callDelegate = function (array $args) use ($apiCall, $unreachable) {
296+
$response = call_user_func($apiCall, $args);
297+
if (isset($response['unreachable']) && is_array($response['unreachable'])) {
298+
$current = $unreachable->getArrayCopy();
299+
$updated = array_unique(array_merge($current, $response['unreachable']));
300+
$unreachable->exchangeArray($updated);
301+
}
302+
return $response;
303+
};
304+
305+
// Return the new BucketIterator with the wrapped unreachable bucket
306+
return new BucketIterator(
294307
new PageIterator(
295308
function (array $bucket) use ($userProject) {
296309
return new Bucket(
@@ -299,10 +312,11 @@ function (array $bucket) use ($userProject) {
299312
$bucket + ['requesterProjectId' => $userProject]
300313
);
301314
},
302-
[$this->connection, 'listBuckets'],
315+
$callDelegate,
303316
$options + ['project' => $this->projectId],
304317
['resultLimit' => $resultLimit]
305-
)
318+
),
319+
$unreachable
306320
);
307321
}
308322

Storage/tests/Unit/StorageClientTest.php

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ public function testRetriesConfiguration(
418418
}
419419

420420
$objects = iterator_to_array($client->bucket('myBucket')->objects());
421-
421+
422422
$this->assertEquals('file.txt', $objects[0]->name());
423423
$this->assertEquals($expectedRemainingResponses, $mockHandler->count());
424424
}
@@ -576,7 +576,7 @@ public function testDelayFunctionsConfiguration()
576576
]);
577577

578578
$objects = iterator_to_array($client->bucket('myBucket')->objects());
579-
579+
580580
$this->assertEquals('file.txt', $objects[0]->name());
581581
$this->assertEquals([100, 200], $capturedDelays);
582582
}
@@ -624,7 +624,7 @@ public function testRetryListenerConfiguration()
624624
];
625625
$mockHandler = new MockHandler($mockResponses);
626626
$handlerStack = HandlerStack::create($mockHandler);
627-
627+
628628
$requestHistory = [];
629629
$handlerStack->push(\GuzzleHttp\Middleware::history($requestHistory));
630630
$guzzleClient = new \GuzzleHttp\Client(['handler' => $handlerStack]);
@@ -658,6 +658,100 @@ public function testRetryListenerConfiguration()
658658
$this->assertFalse($requestHistory[0]['request']->hasHeader('X-Retry-Attempt'));
659659
$this->assertEquals('1', $requestHistory[1]['request']->getHeaderLine('X-Retry-Attempt'));
660660
}
661+
662+
public function testListBucketsReturnPartialSuccess()
663+
{
664+
$expectedUnreachable = [
665+
'projects/_/buckets/unreachable-1',
666+
'projects/_/buckets/unreachable-2',
667+
];
668+
669+
$this->connection->listBuckets(
670+
Argument::withEntry('returnPartialSuccess', true)
671+
)->willReturn([
672+
'nextPageToken' => 'token',
673+
'unreachable' => $expectedUnreachable,
674+
'items' => [
675+
['name' => 'bucket1']
676+
]
677+
], [
678+
'items' => [
679+
['name' => 'bucket2']
680+
]
681+
]);
682+
683+
$this->connection->projectId()
684+
->willReturn(self::PROJECT);
685+
686+
$this->client->___setProperty('connection', $this->connection->reveal());
687+
$responseWrapper = $this->client->buckets(['returnPartialSuccess' => true]);
688+
689+
$this->assertInstanceOf(
690+
\Google\Cloud\Storage\BucketIterator::class,
691+
$responseWrapper
692+
);
693+
$bucket = iterator_to_array($responseWrapper);
694+
695+
$this->assertCount(2, $bucket);
696+
$this->assertEquals('bucket1', $bucket[0]->name());
697+
$this->assertEquals('bucket2', $bucket[1]->name());
698+
$this->assertNotEmpty($responseWrapper->unreachable());
699+
$this->assertEquals($expectedUnreachable, $responseWrapper->unreachable());
700+
}
701+
702+
public function testBucketsIgnoresUnreachableWhenPartialSuccessIsFalse()
703+
{
704+
$this->connection->listBuckets(
705+
Argument::withEntry('returnPartialSuccess', false)
706+
)->willReturn([
707+
'nextPageToken' => 'token',
708+
'items' => [
709+
['name' => 'bucket1']
710+
]
711+
], [
712+
'items' => [
713+
['name' => 'bucket2']
714+
]
715+
]);
716+
717+
$this->connection->projectId()
718+
->willReturn(self::PROJECT);
719+
$this->client->___setProperty('connection', $this->connection->reveal());
720+
$responseWrapper = $this->client->buckets(['returnPartialSuccess' => false]);
721+
$bucket = iterator_to_array($responseWrapper);
722+
723+
$this->assertCount(2, $bucket);
724+
$this->assertEquals('bucket1', $bucket[0]->name());
725+
$this->assertEquals('bucket2', $bucket[1]->name());
726+
$this->assertEmpty($responseWrapper->unreachable());
727+
}
728+
729+
public function testBucketsIgnoresUnreachableWhenOptionIsAbsent()
730+
{
731+
$this->connection->listBuckets(
732+
Argument::withEntry('project', self::PROJECT)
733+
)->willReturn([
734+
'nextPageToken' => 'token',
735+
'items' => [
736+
['name' => 'bucket1']
737+
]
738+
], [
739+
'items' => [
740+
['name' => 'bucket2']
741+
]
742+
]);
743+
744+
$this->connection->projectId()
745+
->willReturn(self::PROJECT);
746+
$this->client->___setProperty('connection', $this->connection->reveal());
747+
$responseWrapper = $this->client->buckets();
748+
$bucket = iterator_to_array($responseWrapper);
749+
750+
$this->assertCount(2, $bucket);
751+
$this->assertEquals('bucket1', $bucket[0]->name());
752+
$this->assertEquals('bucket2', $bucket[1]->name());
753+
$this->assertEmpty($responseWrapper->unreachable());
754+
}
661755
}
662756

663757
//@codingStandardsIgnoreStart

0 commit comments

Comments
 (0)