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
11 changes: 11 additions & 0 deletions src/Database/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,17 @@ abstract public function getSizeOfCollection(string $collection): int;
*/
abstract public function getSizeOfCollectionOnDisk(string $collection): int;

/**
* Estimate the number of documents in a collection from engine statistics, without scanning it.
* Null when the engine keeps no usable estimate, so the caller counts instead.
*
* @throws DatabaseException
*/
public function estimateCount(string $collection): ?int
{
return null;
}

/**
* Estimate maximum number of bytes required to store a document in $collection.
* Byte requirement varies based on column type and size.
Expand Down
8 changes: 8 additions & 0 deletions src/Database/Adapter/Pool.php
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,14 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
return $result;
}

#[\Override]
public function estimateCount(string $collection): ?int
{
/** @var int|null $result */
$result = $this->delegate(__FUNCTION__, \func_get_args());
return $result;
}

#[\Override]
public function getSizeOfCollection(string $collection): int
{
Expand Down
42 changes: 42 additions & 0 deletions src/Database/Adapter/Postgres.php
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,48 @@ public function getSizeOfCollectionOnDisk(string $collection): int
return $size;
}

/**
* Live tuples from the statistics collector, which every commit on the primary updates.
* A standby collects none, so it reads reltuples from the last vacuum or analyze instead.
* Shared tables hold every tenant's rows, so they have no per-tenant estimate.
*
* @throws DatabaseException
*/
#[\Override]
public function estimateCount(string $collection): ?int
{
if ($this->sharedTables) {
return null;
}

$name = $this->getTable($this->filter($collection));

$result = $this->dialectBuilder()
->fromNone()
->selectRaw(
'CASE WHEN pg_is_in_recovery() THEN class.reltuples::bigint
ELSE COALESCE(stats.n_live_tup, class.reltuples::bigint) END FROM pg_class AS class
LEFT JOIN pg_stat_user_tables AS stats ON stats.relid = class.oid
WHERE class.oid = to_regclass(?)',
[$name]
)
->build();

try {
$statement = $this->executeResult($result, Event::CollectionRead);
$this->execute($statement);
$estimate = $statement->fetchColumn();
} catch (PDOException $e) {
throw new DatabaseException('Failed to estimate collection count: '.$e->getMessage(), $e->getCode(), $e);
}

if (! \is_numeric($estimate) || $estimate < 0) {
return null;
}

return (int) $estimate;
}

/**
* @throws DatabaseException
*/
Expand Down
1 change: 1 addition & 0 deletions src/Database/Adapter/ReadWritePool.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class ReadWritePool extends Pool
'getSchemaIndexes',
'getSizeOfCollection',
'getSizeOfCollectionOnDisk',
'estimateCount',
'ping',
'id',
];
Expand Down
10 changes: 10 additions & 0 deletions src/Database/Trait/Collections.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,16 @@ public function getSizeOfCollection(string $collection): int
return $this->adapter->getSizeOfCollection($definition->getId());
}

/**
* @throws Exception
*/
public function estimateCount(string $collection): ?int
{
$definition = $this->silent(fn () => $this->getOwnCollection($collection));

return $this->adapter->estimateCount($definition->getId());
}

/**
* @throws DatabaseException
* @throws NotFoundException
Expand Down
43 changes: 43 additions & 0 deletions tests/e2e/Adapter/Scopes/CollectionTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,49 @@ public function testSizeCollection(): void
$this->assertLessThan($size2, $size3);
}

public function testEstimateCount(): void
{
/** @var Database $database */
$database = $this->getDatabase();

$database->createCollection(Collection::create(id: 'estimateTest'));
$database->createAttribute('estimateTest', Attribute::string(key: 'name', size: 255, required: true));

$database->getAuthorization()->skip(function () use ($database) {
for ($i = 0; $i < 10; $i++) {
$database->createDocument('estimateTest', new Document([
'$id' => 'doc'.$i,
'name' => 'name'.$i,
]));
}
});

if (! $this->engineIs(Postgres::class) || $database->getAdapter()->hasSharedTables()) {
$this->assertNull($database->estimateCount('estimateTest'));

return;
}

// Postgres publishes a backend's pending statistics at most once a second, when a transaction ends,
// and adds any still pending on top of what ANALYZE records.
sleep(2);
$database->count('estimateTest');
$database->analyzeCollection('estimateTest');

$this->assertSame(10, $database->estimateCount('estimateTest'));

$database->getAuthorization()->skip(function () use ($database) {
for ($i = 0; $i < 10; $i++) {
$database->deleteDocument('estimateTest', 'doc'.$i);
}
});

sleep(2);
$database->count('estimateTest');

$this->assertSame(0, $database->estimateCount('estimateTest'));
}

public function testSizeCollectionOnDisk(): void
{
$this->getDatabase()->createCollection(Collection::create(id: 'sizeTestDisk1'));
Expand Down
51 changes: 50 additions & 1 deletion tests/unit/Adapter/PostgresCollectionSizeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,55 @@ public function testAFailedSizeReadIsADatabaseError(string $method, string $func
}
}

public function testTheEstimateReadsTableStatistics(): void
{
$adapter = $this->adapter(['3000000']);

$this->assertSame(3000000, $adapter->estimateCount('books'));
$this->assertCount(1, $this->statements);
$this->assertStringContainsString('n_live_tup', $this->statements[0]);
$this->assertStringContainsString('reltuples', $this->statements[0]);
}

/**
* @return iterable<string, array{string|false}>
*/
public static function missingEstimates(): iterable
{
yield 'never analyzed' => ['-1'];
yield 'missing table' => [false];
}

#[DataProvider('missingEstimates')]
public function testNoStatisticsIsNoEstimate(string|false $estimate): void
{
$adapter = $this->adapter([$estimate]);

$this->assertNull($adapter->estimateCount('books'));
}

public function testSharedTablesHaveNoEstimate(): void
{
$adapter = $this->adapter(['3000000']);
$adapter->setSharedTables(true);

$this->assertNull($adapter->estimateCount('books'));
$this->assertSame([], $this->statements);
}

public function testAFailedEstimateIsADatabaseError(): void
{
$adapter = $this->adapter(['3000000'], new PDOException('SQLSTATE[08006]: connection failure'));

try {
$adapter->estimateCount('books');
$this->fail('An estimate that fails must reach the caller');
} catch (DatabaseException $error) {
$this->assertSame('Failed to estimate collection count: SQLSTATE[08006]: connection failure', $error->getMessage());
$this->assertInstanceOf(PDOException::class, $error->getPrevious());
}
}

private function readSize(Postgres $adapter, string $method): int
{
return $method === 'getSizeOfCollectionOnDisk'
Expand All @@ -60,7 +109,7 @@ private function readSize(Postgres $adapter, string $method): int
}

/**
* @param list<string> $sizes
* @param list<string|false> $sizes
*/
private function adapter(array $sizes, ?PDOException $failure = null): Postgres
{
Expand Down
Loading