Skip to content

ext/pdo_pgsql: Add Pdo\Pgsql::ATTR_CHUNK_SIZE for chunked result fetching - #23210

Open
KentarouTakeda wants to merge 5 commits into
php:masterfrom
KentarouTakeda:pdo-pgsql-attr-chunk-size
Open

ext/pdo_pgsql: Add Pdo\Pgsql::ATTR_CHUNK_SIZE for chunked result fetching#23210
KentarouTakeda wants to merge 5 commits into
php:masterfrom
KentarouTakeda:pdo-pgsql-attr-chunk-size

Conversation

@KentarouTakeda

Copy link
Copy Markdown
Contributor

Note: this PR currently also contains the bug fix commits from #23065. The feature is independent of them, but the existing lazy fetch defects break this feature's tests too. Once #23065 is merged, rebasing will leave only the last commit here.

Summary

Adds chunked fetching through Pdo\Pgsql::ATTR_CHUNK_SIZE. It generalizes the lazy fetch from #15287, and the chunk size lets you choose the balance between memory and speed. ext/pgsql got this as pg_set_chunked_rows_size in #14571, but there was no way to use it from ext/pdo_pgsql.

Benchmark

Benchmark code
<?php
$pdo = new Pdo\Pgsql('pgsql:');

$pdo->exec(<<<SQL
    drop table if exists bench;

    create temp table bench as select
        g as id,
        md5(g::text) c1,
        'user_'||g as c2,
        (g%997)::int as c3,
        (g*1.5)::numeric(12,2) as c4,
        timestamp '2020-01-01'+(g||' seconds')::interval as c5
    from
        generate_series(1,500000) g;

    analyze bench;
SQL);

$out = fopen('/dev/null', 'w');

$modes = [
    'buffered' => [],
    'ATTR_PREFETCH => 0' => [PDO::ATTR_PREFETCH => 0],
    'ATTR_CHUNK_SIZE => 1' => [Pdo\Pgsql::ATTR_CHUNK_SIZE => 1],
    'ATTR_CHUNK_SIZE => 10' => [Pdo\Pgsql::ATTR_CHUNK_SIZE => 10],
    'ATTR_CHUNK_SIZE => 100' => [Pdo\Pgsql::ATTR_CHUNK_SIZE => 100],
    'ATTR_CHUNK_SIZE => 1000' => [Pdo\Pgsql::ATTR_CHUNK_SIZE => 1000],
];

$held = $rows = $ms = [];

for ($round = -1; $round < 9; $round++) {
    foreach ($modes as $name => $options) {
        $t0 = hrtime(true);

        $statement = $pdo->prepare('select * from bench', $options);
        $statement->execute();

        $held[$name] = $statement->getAttribute(Pdo\Pgsql::ATTR_RESULT_MEMORY_SIZE);
        $rows[$name] = $statement->rowCount();

        while ($row = $statement->fetch(PDO::FETCH_ASSOC)) {
            fputcsv($out, $row, escape: '');
        }

        unset($statement);

        // round -1 warms up
        if ($round >= 0) {
            $ms[$name][] = (hrtime(true) - $t0) / 1e6;
        }
    }
}

fputcsv(STDOUT, ['mode', 'median_ms', 'bytes_held', 'rows_per_fetch'], escape: '');

foreach ($ms as $name => $times) {
    sort($times);
    fputcsv(STDOUT, [$name, round($times[4]), $held[$name], $rows[$name]], escape: '');
}

Run on a build without --enable-debug (PostgreSQL 18.4 / libpq 18.4):

mode median_ms bytes_held rows_per_fetch
buffered 527 97,231,064 500,000
ATTR_PREFETCH => 0 522 3,288 1
ATTR_CHUNK_SIZE => 1 518 3,288 1
ATTR_CHUNK_SIZE => 10 484 3,288 10
ATTR_CHUNK_SIZE => 100 442 19,672 100
ATTR_CHUNK_SIZE => 1000 429 188,632 1,000

Usage

Set on the connection:

$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 1000);

Set on the statement, overriding the connection level:

$pdo->prepare(
    'select * from bench',
    [Pdo\Pgsql::ATTR_CHUNK_SIZE => 1000]
);

Design note

The part in bold is where I am not confident that the behavior is right. I would appreciate feedback.

  • ATTR_CHUNK_SIZE overrides ATTR_PREFETCH.
    • When ATTR_PREFETCH is left unset and only ATTR_CHUNK_SIZE is given, this is treated as a request for chunked fetching.
    • It also overrides an explicit ATTR_PREFETCH => 0. The behavior changes from lazy to chunk. ATTR_CHUNK_SIZE => 1 behaves the same as lazy, and once a chunk size is given, I think honoring it is the natural behavior.
  • ATTR_CHUNK_SIZE and ATTR_CURSOR => CURSOR_SCROLL are mutually exclusive.
    • A statement-level option takes precedence over the connection level, so a statement-level CURSOR_SCROLL disables a connection-level ATTR_CHUNK_SIZE.
    • When both are given at the statement level, there is no basis for deciding precedence, so a ValueError is thrown.

try {
$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, -1);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
echo $e->getMessage(), "\n";
echo $e::class, ': ', $e->getMessage(), "\n";

try {
$pdo->prepare("SELECT 1", [Pdo\Pgsql::ATTR_CHUNK_SIZE => -42]);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
echo $e->getMessage(), "\n";
echo $e::class, ': ', $e->getMessage(), "\n";

new PDO(getenv('PDOTEST_DSN'), $user === false ? null : $user,
$pass === false ? null : $pass, [Pdo\Pgsql::ATTR_CHUNK_SIZE => -1]);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
echo $e->getMessage(), "\n";
echo $e::class, ': ', $e->getMessage(), "\n";

printf("%-8s accepted as %d\n", get_debug_type($value),
$pdo->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE));
} catch (TypeError $e) {
printf("%-8s %s\n", get_debug_type($value), $e->getMessage());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
printf("%-8s %s\n", get_debug_type($value), $e->getMessage());
printf("%-8s %s: %s\n", get_debug_type($value), $e::class, $e->getMessage());

Pdo\Pgsql::ATTR_CHUNK_SIZE => 4,
]);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
echo $e->getMessage(), "\n";
echo $e::class, ': ', $e->getMessage(), "\n";

?>
--EXPECT--
=== a negative value is refused by setAttribute() ===
Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647
ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647

Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647
the attribute kept its previous value: int(0)
=== and by the prepare() driver options ===
Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647
ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647

=== and by the prepare() driver options ===
Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647
=== and by the constructor options ===
Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647
ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647

Comment on lines +87 to +91
float Attribute value must be of type int for selected attribute, float given
string accepted as 8
array Attribute value must be of type int for selected attribute, array given
null Attribute value must be of type int for selected attribute, null given
stdClass Attribute value must be of type int for selected attribute, stdClass given

@NickSdot NickSdot Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
float Attribute value must be of type int for selected attribute, float given
string accepted as 8
array Attribute value must be of type int for selected attribute, array given
null Attribute value must be of type int for selected attribute, null given
stdClass Attribute value must be of type int for selected attribute, stdClass given
float TypeError: Attribute value must be of type int for selected attribute, float given
string accepted as 8
array TypeError: Attribute value must be of type int for selected attribute, array given
null TypeError: Attribute value must be of type int for selected attribute, null given
stdClass TypeError: Attribute value must be of type int for selected attribute, stdClass given

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

string is accepted, so this line comes from the try branch, not the catch. Prefixing it with TypeError: fails the test.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right. Updated!

null Attribute value must be of type int for selected attribute, null given
stdClass Attribute value must be of type int for selected attribute, stdClass given
=== scrollable cursors have nothing to stream ===
Pdo\Pgsql::ATTR_CHUNK_SIZE cannot be combined with PDO::ATTR_CURSOR set to PDO::CURSOR_SCROLL

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Pdo\Pgsql::ATTR_CHUNK_SIZE cannot be combined with PDO::ATTR_CURSOR set to PDO::CURSOR_SCROLL
ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE cannot be combined with PDO::ATTR_CURSOR set to PDO::CURSOR_SCROLL

try {
$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 2147483648);
} catch (ValueError $e) {
echo "setAttribute(): ", $e->getMessage(), "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
echo "setAttribute(): ", $e->getMessage(), "\n";
echo 'setAttribute(): ', $e::class, ': ', $e->getMessage(), "\n";

try {
$pdo->prepare("SELECT 1", [Pdo\Pgsql::ATTR_CHUNK_SIZE => PHP_INT_MAX]);
} catch (ValueError $e) {
echo "prepare(): ", $e->getMessage(), "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
echo "prepare(): ", $e->getMessage(), "\n";
echo 'prepare(): ', $e::class, ': ', $e->getMessage(), "\n";

=== INT_MAX is the largest chunk size ===
accepted as: int(2147483647)
=== one more is refused ===
setAttribute(): Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
setAttribute(): Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647
setAttribute(): ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647

accepted as: int(2147483647)
=== one more is refused ===
setAttribute(): Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647
prepare(): Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
prepare(): Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647
prepare(): ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647

@KentarouTakeda

Copy link
Copy Markdown
Contributor Author

Left one correction inline.

catch (ValueError $e) already fails the test if anything else is thrown, so printing the class does not strengthen the assertion. I would rather not preempt #22799 while it is still under review, and will follow whatever it settles on.

@NickSdot

Copy link
Copy Markdown
Contributor

Left one correction inline.

catch (ValueError $e) already fails the test if anything else is thrown, so printing the class does not strengthen the assertion. I would rather not preempt #22799 while it is still under review, and will follow whatever it settles on.

A good chunk of #22799 is merged in extracted PRs. The changes I proposed here are not part of the still "under discussion" questions -- what I propose here gets regularly merged, last one here today: #23184.

Sorry about the one I missed!

@KentarouTakeda
KentarouTakeda force-pushed the pdo-pgsql-attr-chunk-size branch from ff41006 to 62266ca Compare August 11, 2026 10:25
@KentarouTakeda

Copy link
Copy Markdown
Contributor Author

Applied.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants