On Mar 15, 2026, Tomas Vondra <[email protected]> wrote:
> The first line "Prefetch" tracks the look-ahead distance, i.e. how many
> blocks ahead the ReadStream is requesting.
> The second line "I/O" is about the I/O requests actually issued - how
> many times we had to wait for the block (when we get to process it),
> average size of a request (in BLCKSZ blocks), and average number of
> in-progress requests.

Our new AI harness for testing found that a rescan can include buffers that
never reach the consumer in the Prefetch average, and prepared the attached
patch.  On a clean cluster with io_method=worker, this is a complete
reproducer:

    \set ON_ERROR_STOP on
    create extension pg_buffercache;
    set jit = off;
    set max_parallel_workers_per_gather = 0;
    set enable_seqscan = off;
    set effective_io_concurrency = 16;

    create unlogged table reset_t as
    select g as id, repeat(md5(g::text), 16) as payload
    from generate_series(1, 80000) as g;
    vacuum (analyze, freeze) reset_t;
    checkpoint;
    select pg_buffercache_evict_relation('reset_t');

    explain (analyze, buffers, io, timing off, summary off, costs off)
    select r.startblock, s.ctid
    from (values (0), (300), (600), (900), (1200),
                 (1500), (1800), (2100), (2400), (2700)) r(startblock)
    cross join lateral (
        select t.ctid
        from reset_t t
        where t.ctid >= format('(%s,1)', r.startblock)::tid
          and t.ctid < format('(%s,1)', r.startblock + 200)::tid
        offset 0 limit 1
    ) s;

The inner TID Range Scan reports:

    Prefetch: avg=1.32 max=2
    I/O: count=20 waits=19 size=1.50 in-progress=1.00
    Buffers: shared read=30

Each of the ten loops returns one buffer to the consumer at distance one.
A single-loop control reports avg=1.00 max=1.

read_stream_reset() drains unread buffers by calling
read_stream_next_buffer(), which also calls read_stream_count_prefetch().
The nine rescans above add 18 cleanup samples with distance sum 27, so the
reported average is (10 + 27) / (10 + 18) = 1.321428... and max becomes 2.

The attached patch preserves prefetch_count, distance_sum, and distance_max
around that internal drain, while leaving the real I/O statistics
cumulative.  It adds a test that checks all three fields with worker and
sync I/O.

On master d39fda1c the test fails without the read_stream.c change and
passes with it; the full test_aio and core regression suites pass.  The
same patch applies to REL_19_STABLE b73d13c3, where the reproducer shows
the same avg=1.32 max=2 result and test_aio passes with the fix.

Nik
diff --git a/src/backend/storage/aio/read_stream.c b/src/backend/storage/aio/read_stream.c
index fddf6ef441d814abf3dbbbebfa344ae9d827d678..852cc958560832270b726c3bf620169fbc13eaa8 100644
--- a/src/backend/storage/aio/read_stream.c
+++ b/src/backend/storage/aio/read_stream.c
@@ -1439,6 +1439,9 @@ read_stream_reset(ReadStream *stream)
 {
 	int16		index;
 	Buffer		buffer;
+	uint64		prefetch_count = 0;
+	uint64		distance_sum = 0;
+	int16		distance_max = 0;
 
 	/* Stop looking ahead. */
 	stream->readahead_distance = 0;
@@ -1451,10 +1454,31 @@ read_stream_reset(ReadStream *stream)
 	stream->buffered_blocknum = InvalidBlockNumber;
 	stream->fast_path = false;
 
+	/*
+	 * Draining the stream below is an implementation detail, not consumption
+	 * by the stream's caller.  Preserve the prefetch distance statistics so
+	 * that the drained buffers are not counted as having been returned to the
+	 * caller.  I/O statistics still need to reflect any work done while
+	 * draining.
+	 */
+	if (stream->stats)
+	{
+		prefetch_count = stream->stats->prefetch_count;
+		distance_sum = stream->stats->distance_sum;
+		distance_max = stream->stats->distance_max;
+	}
+
 	/* Unpin anything that wasn't consumed. */
 	while ((buffer = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
 		ReleaseBuffer(buffer);
 
+	if (stream->stats)
+	{
+		stream->stats->prefetch_count = prefetch_count;
+		stream->stats->distance_sum = distance_sum;
+		stream->stats->distance_max = distance_max;
+	}
+
 	/* Unpin any unused forwarded buffers. */
 	index = stream->next_buffer_index;
 	while (index < stream->initialized_buffers &&
diff --git a/src/test/modules/test_aio/t/004_read_stream.pl b/src/test/modules/test_aio/t/004_read_stream.pl
index 32311c07ac02bb3ebe21be687dc54bcd5ced0be3..758a36aadc89b99355e4eb41d07652183d68ba11 100644
--- a/src/test/modules/test_aio/t/004_read_stream.pl
+++ b/src/test/modules/test_aio/t/004_read_stream.pl
@@ -115,6 +115,22 @@ sub test_repeated_blocks
 }
 
 
+sub test_reset_stats
+{
+	my $io_method = shift;
+	my $node = shift;
+
+	$node->safe_psql('postgres', qq/SELECT evict_rel('largeish');/);
+	is(
+		$node->safe_psql(
+			'postgres',
+			qq/SELECT read_stream_reset_stats('largeish', ARRAY[0, 1, 2, 3]);/),
+		't',
+		"$io_method: resetting a stream preserves consumer prefetch statistics"
+	);
+}
+
+
 sub test_inject_foreign
 {
 	my $io_method = shift;
@@ -267,6 +283,7 @@ sub test_io_method
 	is($node->safe_psql('postgres', 'SHOW io_method'),
 		$io_method, "$io_method: io_method set correctly");
 
+	test_reset_stats($io_method, $node);
 	test_repeated_blocks($io_method, $node);
 
   SKIP:
diff --git a/src/test/modules/test_aio/test_aio--1.0.sql b/src/test/modules/test_aio/test_aio--1.0.sql
index 762ac29512f7fc1dabfdc47dfe6840bf840832f3..b5948eb8c50b0ca616be15dcc6b1dcbde0b35c41 100644
--- a/src/test/modules/test_aio/test_aio--1.0.sql
+++ b/src/test/modules/test_aio/test_aio--1.0.sql
@@ -64,6 +64,10 @@ CREATE FUNCTION read_stream_for_blocks(rel regclass, blocks int4[], OUT blockoff
 RETURNS SETOF record STRICT
 AS 'MODULE_PATHNAME' LANGUAGE C;
 
+CREATE FUNCTION read_stream_reset_stats(rel regclass, blocks int4[])
+RETURNS pg_catalog.bool STRICT
+AS 'MODULE_PATHNAME' LANGUAGE C;
+
 
 /*
  * Handle related functions
diff --git a/src/test/modules/test_aio/test_aio.c b/src/test/modules/test_aio/test_aio.c
index 39d857557cfaf7cabe6c9c4b8b0090dec5c5bc78..7594d2efcce4021154a8fa8d4542bed9d2028c2b 100644
--- a/src/test/modules/test_aio/test_aio.c
+++ b/src/test/modules/test_aio/test_aio.c
@@ -20,6 +20,7 @@
 
 #include "access/relation.h"
 #include "catalog/pg_type.h"
+#include "executor/instrument_node.h"
 #include "fmgr.h"
 #include "funcapi.h"
 #include "storage/aio.h"
@@ -916,6 +917,62 @@ read_stream_for_blocks(PG_FUNCTION_ARGS)
 }
 
 
+PG_FUNCTION_INFO_V1(read_stream_reset_stats);
+Datum
+read_stream_reset_stats(PG_FUNCTION_ARGS)
+{
+	Oid			relid = PG_GETARG_OID(0);
+	ArrayType  *blocksarray = PG_GETARG_ARRAYTYPE_P(1);
+	Relation	rel;
+	BlocksReadStreamData stream_data;
+	ReadStream *stream;
+	IOStats		stats = {0};
+	Buffer		buf;
+	uint64		prefetch_count;
+	uint64		distance_sum;
+	int16		distance_max;
+
+	if (ARR_NDIM(blocksarray) != 1 ||
+		ARR_HASNULL(blocksarray) ||
+		ARR_ELEMTYPE(blocksarray) != INT4OID ||
+		ARR_DIMS(blocksarray)[0] == 0)
+		elog(ERROR, "expected non-empty 1 dimensional int4 array");
+
+	stream_data.curblock = 0;
+	stream_data.nblocks = ARR_DIMS(blocksarray)[0];
+	stream_data.blocks = (uint32 *) ARR_DATA_PTR(blocksarray);
+
+	rel = relation_open(relid, AccessShareLock);
+	stream = read_stream_begin_relation(READ_STREAM_FULL,
+										NULL,
+										rel,
+										MAIN_FORKNUM,
+										read_stream_for_blocks_cb,
+										&stream_data,
+										0);
+	read_stream_enable_stats(stream, &stats);
+
+	buf = read_stream_next_buffer(stream, NULL);
+	if (!BufferIsValid(buf))
+		elog(ERROR, "first read_stream_next_buffer() call is unexpectedly invalid");
+	ReleaseBuffer(buf);
+
+	if (stats.prefetch_count != 1)
+		elog(ERROR, "expected exactly one prefetch sample");
+	prefetch_count = stats.prefetch_count;
+	distance_sum = stats.distance_sum;
+	distance_max = stats.distance_max;
+
+	read_stream_reset(stream);
+	read_stream_end(stream);
+	relation_close(rel, NoLock);
+
+	PG_RETURN_BOOL(stats.prefetch_count == prefetch_count &&
+				   stats.distance_sum == distance_sum &&
+				   stats.distance_max == distance_max);
+}
+
+
 PG_FUNCTION_INFO_V1(handle_get);
 Datum
 handle_get(PG_FUNCTION_ARGS)

Reply via email to