Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ public function list(Path $path, Filter $pathFilter = new KeepAll()) : \Generato
{
$this->mount->supports($path) || throw new InvalidSchemeException($path->protocol(), $this->mount->protocol);

if (!$path->isPattern()
&& $this->options->fileFastPath()
&& $path->extension() !== false
&& !\str_ends_with($path->path(), DIRECTORY_SEPARATOR)) {
try {
$headObject = $this->s3Client->headObject([
'Bucket' => $this->bucket,
'Key' => \ltrim($path->path(), DIRECTORY_SEPARATOR),
]);
$headObject->resolve();

$fileStatus = new FileStatus(
$path,
true,
$headObject->getContentLength(),
$headObject->getLastModified(),
);

if ($pathFilter->accept($fileStatus)) {
yield $fileStatus;
}

return;
} catch (NoSuchKeyException) {
// Not a single object - fall through to listing.
}
}

if ($path->isPattern()) {
$prefix = \ltrim($path->staticPart()->path(), DIRECTORY_SEPARATOR);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ final class Options
{
private readonly BlockFactory $blockFactory;

private bool $fileFastPath = true;

private int $partSize = 1024 * 1024 * 5;

private readonly Path $tmpDir;
Expand All @@ -29,6 +31,11 @@ public function blockFactory() : BlockFactory
return $this->blockFactory;
}

public function fileFastPath() : bool
{
return $this->fileFastPath;
}

public function partSize() : int
{
return $this->partSize;
Expand All @@ -49,4 +56,17 @@ public function withBlockSize(int $bytes) : self

return $this;
}

/**
* When enabled (default), list() on a single-file path will first attempt a HEAD on the object.
* If the object exists, list() yields just that single FileStatus and never issues listObjectsV2.
* This avoids the s3:ListBucket permission requirement and an extra round-trip when reading single files.
* Disable if your workloads typically pass folder/prefix paths to list(), to skip the (failing) HEAD.
*/
public function withFileFastPath(bool $enabled = true) : self
{
$this->fileFastPath = $enabled;

return $this;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Flow\Filesystem\Bridge\AsyncAWS\Tests\Double;

use AsyncAws\S3\Result\{
CopyObjectOutput,
DeleteObjectOutput,
HeadObjectOutput,
ListObjectsV2Output,
};
use AsyncAws\S3\S3Client;

final class RecordingS3Client extends S3Client
{
public int $copyObjectCount = 0;

public int $deleteObjectCount = 0;

public int $headObjectCount = 0;

public int $listObjectsV2Count = 0;

#[\Override]
public function copyObject($input) : CopyObjectOutput
{
$this->copyObjectCount++;

return parent::copyObject($input);
}

#[\Override]
public function deleteObject($input) : DeleteObjectOutput
{
$this->deleteObjectCount++;

return parent::deleteObject($input);
}

#[\Override]
public function headObject($input) : HeadObjectOutput
{
$this->headObjectCount++;

return parent::headObject($input);
}

#[\Override]
public function listObjectsV2($input) : ListObjectsV2Output
{
$this->listObjectsV2Count++;

return parent::listObjectsV2($input);
}

public function resetCounters() : void
{
$this->copyObjectCount = 0;
$this->deleteObjectCount = 0;
$this->headObjectCount = 0;
$this->listObjectsV2Count = 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

declare(strict_types=1);

namespace Flow\Filesystem\Bridge\AsyncAWS\Tests\Integration;

use function Flow\Filesystem\Bridge\AsyncAWS\DSL\aws_s3_filesystem;
use function Flow\Filesystem\DSL\path;
use Flow\Filesystem\Bridge\AsyncAWS\Options;
use Flow\Filesystem\Bridge\AsyncAWS\Tests\Double\RecordingS3Client;
use Flow\Filesystem\Path\Filter\OnlyFiles;
use Flow\Filesystem\Tests\Double\RejectingFilter;

final class AsyncAWSS3FilesystemFileFastPathTest extends AsyncAWSS3TestCase
{
public function test_list_disabled_fast_path_falls_back_to_listing_for_single_file() : void
{
$client = $this->recordingClient();
$fs = aws_s3_filesystem(
$this->bucket(),
$client,
(new Options())->withFileFastPath(false),
);

$fs->writeTo(path('aws-s3://var/orders/orders.csv'))->append('a,b')->close();

$client->resetCounters();

$statuses = \iterator_to_array($fs->list(path('aws-s3://var/orders/orders.csv')));

self::assertCount(1, $statuses);
self::assertSame(0, $client->headObjectCount, 'HEAD must not be issued when fast path is disabled');
self::assertSame(1, $client->listObjectsV2Count, 'listObjectsV2 must run when fast path is disabled');
}

public function test_list_filter_is_applied_on_single_file_fast_path() : void
{
$client = $this->recordingClient();
$fs = aws_s3_filesystem($this->bucket(), $client);

$fs->writeTo(path('aws-s3://var/orders/orders.csv'))->append('a,b')->close();

$client->resetCounters();

$statuses = \iterator_to_array(
$fs->list(path('aws-s3://var/orders/orders.csv'), new RejectingFilter()),
);

self::assertCount(0, $statuses);
self::assertSame(1, $client->headObjectCount);
self::assertSame(0, $client->listObjectsV2Count);
}

public function test_list_folder_path_with_trailing_slash_skips_head() : void
{
$client = $this->recordingClient();
$fs = aws_s3_filesystem($this->bucket(), $client);

$fs->writeTo(path('aws-s3://var/orders/a.csv'))->append('a')->close();
$fs->writeTo(path('aws-s3://var/orders/b.csv'))->append('b')->close();

$client->resetCounters();

$statuses = \iterator_to_array($fs->list(path('aws-s3://var/orders/')));

self::assertCount(2, $statuses);
self::assertSame(0, $client->headObjectCount, 'HEAD must be skipped for paths ending with /');
self::assertSame(1, $client->listObjectsV2Count);
}

public function test_list_non_existing_single_file_path_falls_back_to_listing() : void
{
$client = $this->recordingClient();
$fs = aws_s3_filesystem($this->bucket(), $client);

$client->resetCounters();

$statuses = \iterator_to_array($fs->list(path('aws-s3://var/missing/file.csv')));

self::assertCount(0, $statuses);
self::assertSame(1, $client->headObjectCount, 'HEAD is attempted on non-pattern, non-folder path');
self::assertSame(1, $client->listObjectsV2Count, 'fallback listing runs after NoSuchKey');
}

public function test_list_pattern_skips_head() : void
{
$client = $this->recordingClient();
$fs = aws_s3_filesystem($this->bucket(), $client);

$fs->writeTo(path('aws-s3://var/orders/a.csv'))->append('a')->close();
$fs->writeTo(path('aws-s3://var/orders/b.csv'))->append('b')->close();

$client->resetCounters();

$statuses = \iterator_to_array($fs->list(path('aws-s3://var/orders/*.csv')));

self::assertCount(2, $statuses);
self::assertSame(0, $client->headObjectCount, 'HEAD must not be issued for pattern paths');
self::assertSame(1, $client->listObjectsV2Count);
}

public function test_list_root_path_skips_head() : void
{
$client = $this->recordingClient();
$fs = aws_s3_filesystem($this->bucket(), $client);

$fs->writeTo(path('aws-s3://root.txt'))->append('x')->close();

$client->resetCounters();

\iterator_to_array($fs->list(path('aws-s3:///')));

self::assertSame(0, $client->headObjectCount);
self::assertSame(1, $client->listObjectsV2Count);
}

public function test_list_single_file_does_not_yield_prefix_siblings() : void
{
$client = $this->recordingClient();
$fs = aws_s3_filesystem($this->bucket(), $client);

$fs->writeTo(path('aws-s3://var/orders/file.txt'))->append('a')->close();
$fs->writeTo(path('aws-s3://var/orders/file.txt.bak'))->append('b')->close();

$client->resetCounters();

$statuses = \iterator_to_array($fs->list(path('aws-s3://var/orders/file.txt')));

self::assertCount(1, $statuses);
self::assertSame('aws-s3://var/orders/file.txt', $statuses[0]->path->uri());
self::assertSame(1, $client->headObjectCount);
self::assertSame(0, $client->listObjectsV2Count);
}

public function test_list_single_file_yields_via_head_only_when_fast_path_enabled() : void
{
$client = $this->recordingClient();
$fs = aws_s3_filesystem($this->bucket(), $client);

$fs->writeTo(path('aws-s3://var/orders/orders.csv'))->append('a,b,c')->close();

$client->resetCounters();

$statuses = \iterator_to_array($fs->list(path('aws-s3://var/orders/orders.csv'), new OnlyFiles()));

self::assertCount(1, $statuses);
self::assertSame('aws-s3://var/orders/orders.csv', $statuses[0]->path->uri());
self::assertTrue($statuses[0]->isFile());
self::assertSame(1, $client->headObjectCount, 'exactly one HEAD must be issued');
self::assertSame(0, $client->listObjectsV2Count, 'listObjectsV2 must NOT be issued for single-file fast path');
}

private function recordingClient() : RecordingS3Client
{
$configuration = [
'pathStyleEndpoint' => true,
'endpoint' => $_ENV['S3_ENDPOINT'],
'region' => $_ENV['S3_REGION'],
'accessKeyId' => $_ENV['S3_ACCESS_KEY_ID'],
'accessKeySecret' => $_ENV['S3_SECRET_ACCESS_KEY'],
];

/** @phpstan-ignore-next-line */
return new RecordingS3Client($configuration);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ public function list(Path $path, Filter $pathFilter = new KeepAll()) : \Generato
{
$this->mount->supports($path) || throw new InvalidSchemeException($path->protocol(), $this->mount->protocol);

if (!$path->isPattern()
&& $this->options->fileFastPath()
&& $path->extension() !== false
&& !\str_ends_with($path->path(), DIRECTORY_SEPARATOR)) {
$blobProperties = $this->blobService->getBlobProperties(\ltrim($path->path(), DIRECTORY_SEPARATOR));

if ($blobProperties !== null) {
$fileStatus = new FileStatus(
$path,
true,
$blobProperties->size(),
$blobProperties->lastModifiedAt(),
);

if ($pathFilter->accept($fileStatus)) {
yield $fileStatus;
}

return;
}
}

if ($path->isPattern()) {
$prefix = \ltrim($path->staticPart()->path(), DIRECTORY_SEPARATOR);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ final class Options

private int $blockSize = 1024 * 1024 * 4;

private bool $fileFastPath = true;

/**
* @var null|array<OptionInclude>
*/
Expand Down Expand Up @@ -42,6 +44,11 @@ public function blockSize() : int
return $this->blockSize;
}

public function fileFastPath() : bool
{
return $this->fileFastPath;
}

public function listBlobOptions() : ListBlobOptions
{
$listBlobOptions = new ListBlobOptions();
Expand Down Expand Up @@ -80,6 +87,19 @@ public function withBlockSize(int $blockSize) : self
return $this;
}

/**
* When enabled (default), list() on a single-file path will first attempt getBlobProperties on the blob.
* If it exists, list() yields just that single FileStatus and never issues listBlobs.
* This avoids the container-list permission requirement and an extra round-trip when reading single files.
* Disable if your workloads typically pass folder/prefix paths to list(), to skip the (failing) properties call.
*/
public function withFileFastPath(bool $enabled = true) : self
{
$this->fileFastPath = $enabled;

return $this;
}

public function withListBlobInclude(OptionInclude ...$listBlobInclude) : self
{
$this->listBlobInclude = $listBlobInclude;
Expand Down
Loading
Loading