From f73b61cbf44e4528c94b7b3b3994d60a16d18c6c Mon Sep 17 00:00:00 2001
From: Dinesh Salve <cooltodinesh@gmail.com>
Date: Tue, 28 Jul 2026 15:57:37 +0000
Subject: [PATCH v1] Add test_bufmgr: algorithm-agnostic tests for the buffer
 manager

The buffer manager (src/backend/storage/buffer/) has no dedicated test
module of its own. It is covered only as a side effect of the regression
suite, so a change to the replacement policy or to the residency
bookkeeping can regress without any test asserting the affected property
directly.

This commit adds src/test/modules/test_bufmgr. It tests the buffer
manager as a subsystem, agnostic of current eviction policy. Every
property asserted is expressed only through observable behavior i.e.
page residency, page contents and buffer pins. Residency is read as
"does pg_buffercache have a row for this relation block", contents as
the page data itself, and pins as pg_buffercache.pinning_backends. It
doesn't rely on usagecount or any other replacement-policy internal. The
intent is that these keep passing across an algorithm swap.

This first change has below regress tests and a isolation spec:
  - eviction_reload: an evicted page returned with correct content, and
    the manager makes progress under pressure (no "no unpinned buffers
    available" error).
  - dirty_persistence: an evicted dirty page reloads with its modified
    contents; eviction itself forces the write-out, with no checkpoint
    or restart needed.
  - scan_no_retain: a one-touch page does not indefinitely retain its
    buffer under pressure.
  - truncate_stale / drop_recreate_stale: TRUNCATE and DROP+recreate
    never return stale cached pages.
  - local_eviction: the local (temp-table) buffer pool reuses correctly,
    sized from temp_buffers in bytes.
  - churn: contents stay correct after many replacement cycles over a
    working set several times the pool.
  - pinned_safety (isolation): a buffer pinned via a suspended cursor in
    one session survives a concurrent flood in another, with an unpinned
    control probe confirming the flood actually exceeded the pool.
---
 src/test/modules/Makefile                     |  1 +
 src/test/modules/meson.build                  |  1 +
 src/test/modules/test_bufmgr/.gitignore       |  6 ++
 src/test/modules/test_bufmgr/Makefile         | 34 +++++++
 src/test/modules/test_bufmgr/README           | 89 +++++++++++++++++
 .../expected/test_bufmgr_churn.out            | 66 +++++++++++++
 .../test_bufmgr_dirty_persistence.out         | 64 ++++++++++++
 .../test_bufmgr_drop_recreate_stale.out       | 39 ++++++++
 .../expected/test_bufmgr_eviction_reload.out  | 71 +++++++++++++
 .../expected/test_bufmgr_local_eviction.out   | 43 ++++++++
 .../expected/test_bufmgr_pinned_safety.out    | 47 +++++++++
 .../expected/test_bufmgr_scan_no_retain.out   | 56 +++++++++++
 .../expected/test_bufmgr_truncate_stale.out   | 28 ++++++
 src/test/modules/test_bufmgr/meson.build      | 28 ++++++
 .../specs/test_bufmgr_pinned_safety.spec      | 99 +++++++++++++++++++
 .../test_bufmgr/sql/test_bufmgr_churn.sql     | 52 ++++++++++
 .../sql/test_bufmgr_dirty_persistence.sql     | 56 +++++++++++
 .../sql/test_bufmgr_drop_recreate_stale.sql   | 21 ++++
 .../sql/test_bufmgr_eviction_reload.sql       | 63 ++++++++++++
 .../sql/test_bufmgr_local_eviction.sql        | 34 +++++++
 .../sql/test_bufmgr_scan_no_retain.sql        | 48 +++++++++
 .../sql/test_bufmgr_truncate_stale.sql        | 14 +++
 .../test_bufmgr/test_bufmgr_small.conf        |  6 ++
 23 files changed, 966 insertions(+)
 create mode 100644 src/test/modules/test_bufmgr/.gitignore
 create mode 100644 src/test/modules/test_bufmgr/Makefile
 create mode 100644 src/test/modules/test_bufmgr/README
 create mode 100644 src/test/modules/test_bufmgr/expected/test_bufmgr_churn.out
 create mode 100644 src/test/modules/test_bufmgr/expected/test_bufmgr_dirty_persistence.out
 create mode 100644 src/test/modules/test_bufmgr/expected/test_bufmgr_drop_recreate_stale.out
 create mode 100644 src/test/modules/test_bufmgr/expected/test_bufmgr_eviction_reload.out
 create mode 100644 src/test/modules/test_bufmgr/expected/test_bufmgr_local_eviction.out
 create mode 100644 src/test/modules/test_bufmgr/expected/test_bufmgr_pinned_safety.out
 create mode 100644 src/test/modules/test_bufmgr/expected/test_bufmgr_scan_no_retain.out
 create mode 100644 src/test/modules/test_bufmgr/expected/test_bufmgr_truncate_stale.out
 create mode 100644 src/test/modules/test_bufmgr/meson.build
 create mode 100644 src/test/modules/test_bufmgr/specs/test_bufmgr_pinned_safety.spec
 create mode 100644 src/test/modules/test_bufmgr/sql/test_bufmgr_churn.sql
 create mode 100644 src/test/modules/test_bufmgr/sql/test_bufmgr_dirty_persistence.sql
 create mode 100644 src/test/modules/test_bufmgr/sql/test_bufmgr_drop_recreate_stale.sql
 create mode 100644 src/test/modules/test_bufmgr/sql/test_bufmgr_eviction_reload.sql
 create mode 100644 src/test/modules/test_bufmgr/sql/test_bufmgr_local_eviction.sql
 create mode 100644 src/test/modules/test_bufmgr/sql/test_bufmgr_scan_no_retain.sql
 create mode 100644 src/test/modules/test_bufmgr/sql/test_bufmgr_truncate_stale.sql
 create mode 100644 src/test/modules/test_bufmgr/test_bufmgr_small.conf

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 098bb8142ae..6e86c3bc70c 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -20,6 +20,7 @@ SUBDIRS = \
 		  test_binaryheap \
 		  test_bitmapset \
 		  test_bloomfilter \
+		  test_bufmgr \
 		  test_cloexec \
 		  test_checksums \
 		  test_copy_callbacks \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..e495d23b07f 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -20,6 +20,7 @@ subdir('test_autovacuum')
 subdir('test_binaryheap')
 subdir('test_bitmapset')
 subdir('test_bloomfilter')
+subdir('test_bufmgr')
 subdir('test_cloexec')
 subdir('test_checksums')
 subdir('test_copy_callbacks')
diff --git a/src/test/modules/test_bufmgr/.gitignore b/src/test/modules/test_bufmgr/.gitignore
new file mode 100644
index 00000000000..0de307e70a6
--- /dev/null
+++ b/src/test/modules/test_bufmgr/.gitignore
@@ -0,0 +1,6 @@
+# Generated subdirectories
+/log/
+/output_iso/
+/results/
+/tmp_check/
+/tmp_check_iso/
diff --git a/src/test/modules/test_bufmgr/Makefile b/src/test/modules/test_bufmgr/Makefile
new file mode 100644
index 00000000000..eb7d87d91e3
--- /dev/null
+++ b/src/test/modules/test_bufmgr/Makefile
@@ -0,0 +1,34 @@
+# src/test/modules/test_bufmgr/Makefile
+
+EXTRA_INSTALL = contrib/pg_buffercache contrib/pg_prewarm
+
+# NOTE: every test here needs a small buffer pool, so that a modest flood can
+# cycle the whole of it; both the regress and the isolation run get that from
+# test_bufmgr_small.conf via --temp-config.
+REGRESS_OPTS = --temp-config $(top_srcdir)/src/test/modules/test_bufmgr/test_bufmgr_small.conf
+REGRESS = test_bufmgr_eviction_reload \
+	test_bufmgr_dirty_persistence \
+	test_bufmgr_scan_no_retain \
+	test_bufmgr_truncate_stale \
+	test_bufmgr_drop_recreate_stale \
+	test_bufmgr_local_eviction \
+	test_bufmgr_churn
+
+ISOLATION = test_bufmgr_pinned_safety
+ISOLATION_OPTS = --temp-config $(top_srcdir)/src/test/modules/test_bufmgr/test_bufmgr_small.conf
+
+# Disabled because these tests only make sense against the small pool set up by
+# test_bufmgr_small.conf, which --temp-config cannot supply to an already
+# running server.
+NO_INSTALLCHECK = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_bufmgr
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_bufmgr/README b/src/test/modules/test_bufmgr/README
new file mode 100644
index 00000000000..8322a37d397
--- /dev/null
+++ b/src/test/modules/test_bufmgr/README
@@ -0,0 +1,89 @@
+test_bufmgr - regression tests for the buffer manager
+-----------------------------------------------------
+
+This module tests the PostgreSQL buffer manager (src/backend/storage/buffer/)
+as a subsystem, not any one replacement algorithm.  It is named for the
+subsystem under test, which is stable, rather than the eviction policy which
+keeps changing.
+
+We test properties that any correct buffer manager must have, expressed only
+through observable behavior i.e. page residency, page contents, and buffer pins
+and never through internal fields. These are meant to keep passing across an
+algorithm swap: on today's usage-count clock sweep, or on a future pluggable
+pool that may not even have a usagecount field.
+
+This test module covers below use cases:
+
+test_bufmgr_eviction_reload    Under pressure whose working set exceeds
+                               shared_buffers, some previously-resident
+                               unpinned page is evicted, reloading it returns
+                               correct contents, and the manager makes
+                               progress (no "no unpinned buffers" error).
+
+test_bufmgr_dirty_persistence  An evicted dirty page is written to disk, so a
+                               reload returns the modified contents.
+
+test_bufmgr_scan_no_retain     A page read once and never again does not
+                               indefinitely retain its buffer under pressure.
+
+test_bufmgr_truncate_stale     TRUNCATE leaves no stale cached pages that
+                               later become visible.
+
+test_bufmgr_drop_recreate_stale
+                               A dropped relation's cached pages never
+                               resurface through a newly created relation.
+
+test_bufmgr_local_eviction     A temp relation larger than temp_buffers stays
+                               correct after local-buffer reuse.
+
+test_bufmgr_churn              Contents stay correct after many
+                               replacement/reload cycles over a working set
+                               much larger than the pool.
+
+test_bufmgr_pinned_safety      (isolation) A pinned buffer is never evicted.
+                               While one backend holds a cursor pin, another
+                               floods past the whole pool; the pinned page
+                               stays resident and unchanged while an unpinned
+                               control block is evicted (proving the flood
+                               really exceeded the pool).
+
+Guiding tenet
+-------------
+
+Core tests asserts observable outcomes, not internal encodings.  Residency is read
+as "is there a pg_buffercache row for this relation block", contents as the
+page data itself, and pins as pg_buffercache.pinning_backends.
+
+Uniquely identifiable pages: each row's payload is a deterministic function of
+its id (md5(id::text)), so any block read back is verified by re-deriving the
+expected payload, with no assumption about how many rows land on a page or
+which page a row occupies so the tests are BLCKSZ independent.
+
+Self-calibrating flood: eviction is forced not by a hand-tuned flood size but
+by a bounded loop that grows a throwaway relation until the *observed* target
+end-state is reached (a tracked block becomes non-resident), then stops so
+the tests assert "reclaim was observed" rather than "N rows happened to be the
+right number on this machine".
+
+Configuration knobs
+-------------------
+
+test_bufmgr_small.conf:
+
+shared_buffers = 8MB          1024 buffers - small enough that a modest flood
+                              forces real reclaim through the whole pool, but
+                              large enough that the test's own setup traffic
+                              doesn't perturb tracked blocks before the flood.
+temp_buffers = 100            Minimum local pool, so test_bufmgr_local_eviction
+                              can exceed it.
+bgwriter_lru_maxpages = 0     With it enabled, BgBufferSync's own demand driven
+                              LRU sweep runs concurrently with a test's flood
+                              and reclaims buffers on its own schedule,
+                              competing for the same clock-hand position.
+autovacuum = off              Remove other background sources of buffer
+                              touches during each test's brief window.
+checkpoint_timeout = 1h       CHECKPOINT clears a buffer's dirty bit without
+                              evicting it, the block stays resident. So a
+                              background checkpoint firing mid-test would make
+                              test flaky.
+client_min_messages = warning Suppresses NOTICEs.
diff --git a/src/test/modules/test_bufmgr/expected/test_bufmgr_churn.out b/src/test/modules/test_bufmgr/expected/test_bufmgr_churn.out
new file mode 100644
index 00000000000..95e03342b87
--- /dev/null
+++ b/src/test/modules/test_bufmgr/expected/test_bufmgr_churn.out
@@ -0,0 +1,66 @@
+-- Test - repeated eviction and reload cycles.
+--
+-- Several relations whose combined working set exceeds the pool, cycled many
+-- times so most blocks are reloaded from storage on every pass. Contents must
+-- remain correct after many replacement/reload cycles.  The test never asserts
+-- which buffers are selected as victims.
+--
+-- Sizes are derived from shared_buffers in bytes (not a fixed row count), so
+-- the combined working set exceeds the pool at any BLCKSZ. Cycling is done
+-- with ring-free pg_prewarm so it drives real pool-wide reuse.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+CREATE EXTENSION IF NOT EXISTS pg_prewarm;
+DROP TABLE IF EXISTS ch_a, ch_b, ch_c;
+CREATE TABLE ch_a (id int, payload text) WITH (autovacuum_enabled = off);
+CREATE TABLE ch_b (id int, payload text) WITH (autovacuum_enabled = off);
+CREATE TABLE ch_c (id int, payload text) WITH (autovacuum_enabled = off);
+-- Fill each relation to roughly one whole pool in bytes, so the three combined
+-- are ~3x shared_buffers and no single pass can stay resident.
+DO $$
+DECLARE
+  target   bigint;
+  tbl      text;
+  rnum       int;
+BEGIN
+  SELECT setting::bigint * current_setting('block_size')::bigint INTO target
+    FROM pg_settings WHERE name = 'shared_buffers';
+  FOREACH tbl IN ARRAY ARRAY['ch_a','ch_b','ch_c'] LOOP
+    rnum := 0;
+    WHILE pg_relation_size(tbl::regclass) < target LOOP
+      EXECUTE format(
+        'INSERT INTO %I SELECT g, %L || md5(g::text) FROM generate_series(%s, %s) g',
+        tbl, right(tbl, 1) || '-', rnum + 1, rnum + 10000);
+      rnum := rnum + 10000;
+    END LOOP;
+  END LOOP;
+END $$;
+-- Cycle through all three many times; each prewarm reloads a whole pool's worth
+-- of blocks, evicting the others.
+DO $$
+BEGIN
+  FOR i IN 1..15 LOOP
+    PERFORM pg_prewarm('ch_a');
+    PERFORM pg_prewarm('ch_b');
+    PERFORM pg_prewarm('ch_c');
+  END LOOP;
+END $$;
+-- After all the churn, every row in every relation must still be correct.
+SELECT count(*) AS bad_a FROM ch_a WHERE payload <> 'a-' || md5(id::text);
+ bad_a 
+-------
+     0
+(1 row)
+
+SELECT count(*) AS bad_b FROM ch_b WHERE payload <> 'b-' || md5(id::text);
+ bad_b 
+-------
+     0
+(1 row)
+
+SELECT count(*) AS bad_c FROM ch_c WHERE payload <> 'c-' || md5(id::text);
+ bad_c 
+-------
+     0
+(1 row)
+
+DROP TABLE ch_a, ch_b, ch_c;
diff --git a/src/test/modules/test_bufmgr/expected/test_bufmgr_dirty_persistence.out b/src/test/modules/test_bufmgr/expected/test_bufmgr_dirty_persistence.out
new file mode 100644
index 00000000000..fda4c9ff1d1
--- /dev/null
+++ b/src/test/modules/test_bufmgr/expected/test_bufmgr_dirty_persistence.out
@@ -0,0 +1,64 @@
+-- Test - dirty-page persistence under eviction pressure.
+--
+-- Test that when a modified (dirty) page is evicted under pressure, the buffer
+-- manager must have written its contents out, so a subsequent reload returns
+-- the modified contents, not the pre-modification ones.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+CREATE EXTENSION IF NOT EXISTS pg_prewarm;
+DROP TABLE IF EXISTS dp_dirt, dp_flood;
+CREATE TABLE dp_dirt (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO dp_dirt SELECT g, 'orig-' || md5(g::text) FROM generate_series(1, 20000) g;
+-- Modify every row: payload becomes a new deterministic value.  All heap pages
+-- holding live tuples are now dirty and must be flushed on eviction.
+UPDATE dp_dirt SET payload = 'mod-' || md5(id::text);
+-- Make the dirty relation fully resident (prewarm hits keep the dirty buffers
+-- dirty; it never cleans them), then record the resident peak.
+SELECT pg_prewarm('dp_dirt') > 0 AS dirt_prewarmed;
+ dirt_prewarmed 
+----------------
+ t
+(1 row)
+
+-- Self-calibrating flood until at least one dirty dp_dirt block has
+-- been evicted (its resident count drops from the peak).  Since every dp_dirt
+-- page is dirty, evicting any of them exercises the write-on-evict path.
+DO $$
+DECLARE
+  peak         bigint;
+  now_resident bigint;
+  first_blk    bigint;
+  last_blk     bigint;
+  rounds       int := 0;
+BEGIN
+  SELECT count(*) INTO peak FROM pg_buffercache
+   WHERE relfilenode = pg_relation_filenode('dp_dirt'::regclass)
+     AND relforknumber = 0;
+
+  CREATE TABLE dp_flood (id int, payload text) WITH (autovacuum_enabled = off);
+  LOOP
+    rounds := rounds + 1;
+    first_blk := pg_relation_size('dp_flood') / current_setting('block_size')::bigint;
+    INSERT INTO dp_flood SELECT g, md5(g::text) FROM generate_series(1, 10000) g;
+    last_blk := pg_relation_size('dp_flood') / current_setting('block_size')::bigint - 1;
+    PERFORM pg_prewarm('dp_flood', 'buffer', 'main', first_blk, last_blk);
+    SELECT count(*) INTO now_resident FROM pg_buffercache
+     WHERE relfilenode = pg_relation_filenode('dp_dirt'::regclass)
+       AND relforknumber = 0;
+    EXIT WHEN now_resident < peak;
+    IF rounds > 200 THEN
+      RAISE EXCEPTION 'no dirty dp_dirt block evicted after % flood rounds (peak=%, now=%)',
+        rounds, peak, now_resident;
+    END IF;
+  END LOOP;
+END $$;
+-- Reload correctness: every row must carry its MODIFIED payload.  A row still
+-- showing an 'orig-' payload would mean an evicted dirty page was not written
+-- out (or was written stale) before reuse.
+SELECT count(*) AS unpersisted_rows FROM dp_dirt
+ WHERE payload <> 'mod-' || md5(id::text);
+ unpersisted_rows 
+------------------
+                0
+(1 row)
+
+DROP TABLE dp_dirt, dp_flood;
diff --git a/src/test/modules/test_bufmgr/expected/test_bufmgr_drop_recreate_stale.out b/src/test/modules/test_bufmgr/expected/test_bufmgr_drop_recreate_stale.out
new file mode 100644
index 00000000000..f0fc6582d2a
--- /dev/null
+++ b/src/test/modules/test_bufmgr/expected/test_bufmgr_drop_recreate_stale.out
@@ -0,0 +1,39 @@
+-- Test - dropping a relation must not let its cached pages resurface through
+-- a later relation that reuses the same relfilenode / blocks.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+DROP TABLE IF EXISTS dr_old, dr_new, dr_flood;
+CREATE TABLE dr_old (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO dr_old SELECT g, 'old-' || md5(g::text) FROM generate_series(1, 5000) g;
+SELECT count(*) FROM dr_old;   -- cache dr_old's pages
+ count 
+-------
+  5000
+(1 row)
+
+DROP TABLE dr_old;
+-- New relation plus flood pressure; the new relation may land on dr_old's
+-- recycled relfilenode/blocks.
+CREATE TABLE dr_new (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO dr_new SELECT g, 'new-' || md5(g::text) FROM generate_series(1, 5000) g;
+CREATE TABLE dr_flood (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO dr_flood SELECT g, md5(g::text) FROM generate_series(1, 30000) g;
+SELECT count(*) FROM dr_flood;
+ count 
+-------
+ 30000
+(1 row)
+
+-- No dr_new row may carry a dropped-relation payload; all must be correct.
+SELECT count(*) AS stale_old_rows FROM dr_new WHERE payload LIKE 'old-%';
+ stale_old_rows 
+----------------
+              0
+(1 row)
+
+SELECT count(*) AS wrong_new_rows FROM dr_new WHERE payload <> 'new-' || md5(id::text);
+ wrong_new_rows 
+----------------
+              0
+(1 row)
+
+DROP TABLE dr_new, dr_flood;
diff --git a/src/test/modules/test_bufmgr/expected/test_bufmgr_eviction_reload.out b/src/test/modules/test_bufmgr/expected/test_bufmgr_eviction_reload.out
new file mode 100644
index 00000000000..b5116c83c55
--- /dev/null
+++ b/src/test/modules/test_bufmgr/expected/test_bufmgr_eviction_reload.out
@@ -0,0 +1,71 @@
+-- Test - eviction and reload correctness:
+--
+-- Property (must hold for ANY correct buffer manager, regardless of its
+-- replacement policy): under allocation pressure whose working set exceeds
+-- shared_buffers, at least one previously-resident, unpinned page is evicted;
+-- reloading any evicted page returns its original contents; and the manager
+-- makes progress (no "no unpinned buffers available" error).
+--
+-- Observed only through pg_buffercache residency (a row present for a relation
+-- block == that block is resident) and through the page contents themselves,
+-- never through usagecount or any replacement-policy internal. The test never
+-- predicts which page is chosen as the victim.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+CREATE EXTENSION IF NOT EXISTS pg_prewarm;
+DROP TABLE IF EXISTS er_victim, er_flood;
+-- Uniquely identifiable pages: each row's payload is a deterministic function
+-- of its id, so any block read back can be verified without assuming how many
+-- rows fall on a page or which page a given row lands on.
+CREATE TABLE er_victim (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO er_victim SELECT g, md5(g::text) FROM generate_series(1, 20000) g;
+SELECT pg_prewarm('er_victim') > 0 AS victim_prewarmed;
+ victim_prewarmed 
+------------------
+ t
+(1 row)
+
+-- Self-calibrating flood: grow a throwaway relation and prewarm each newly
+-- added block range until at least one er_victim block that was
+-- resident has been evicted i.e. er_victim's resident-block count has
+-- strictly dropped from its post-prewarm peak. This asserts an observed
+-- end-state ("reclaim happened") rather than a hand-tuned flood size, and is
+-- bounded by a generous cap so a real failure still terminates the test.
+DO $$
+DECLARE
+  peak         bigint;
+  now_resident bigint;
+  first_blk    bigint;
+  last_blk     bigint;
+  rounds       int := 0;
+BEGIN
+  SELECT count(*) INTO peak FROM pg_buffercache
+   WHERE relfilenode = pg_relation_filenode('er_victim'::regclass)
+     AND relforknumber = 0;
+
+  CREATE TABLE er_flood (id int, payload text) WITH (autovacuum_enabled = off);
+  LOOP
+    rounds := rounds + 1;
+    first_blk := pg_relation_size('er_flood') / current_setting('block_size')::bigint;
+    INSERT INTO er_flood SELECT g, md5(g::text) FROM generate_series(1, 10000) g;
+    last_blk := pg_relation_size('er_flood') / current_setting('block_size')::bigint - 1;
+    PERFORM pg_prewarm('er_flood', 'buffer', 'main', first_blk, last_blk);
+    SELECT count(*) INTO now_resident FROM pg_buffercache
+     WHERE relfilenode = pg_relation_filenode('er_victim'::regclass)
+       AND relforknumber = 0;
+    EXIT WHEN now_resident < peak;    -- a victim block was reclaimed
+    IF rounds > 200 THEN
+      RAISE EXCEPTION 'no er_victim block evicted after % flood rounds (peak=%, now=%)',
+        rounds, peak, now_resident;
+    END IF;
+  END LOOP;
+END $$;
+-- Reload correctness: this full scan re-reads every block from storage,
+-- including the evicted ones, and every row must still carry its original
+-- payload.  A nonzero count would mean a reloaded page returned wrong contents.
+SELECT count(*) AS corrupt_rows FROM er_victim WHERE payload <> md5(id::text);
+ corrupt_rows 
+--------------
+            0
+(1 row)
+
+DROP TABLE er_victim, er_flood;
diff --git a/src/test/modules/test_bufmgr/expected/test_bufmgr_local_eviction.out b/src/test/modules/test_bufmgr/expected/test_bufmgr_local_eviction.out
new file mode 100644
index 00000000000..75ce8b1bd96
--- /dev/null
+++ b/src/test/modules/test_bufmgr/expected/test_bufmgr_local_eviction.out
@@ -0,0 +1,43 @@
+-- Test - local (temporary-table) buffer eviction correctness.
+--
+-- Temporary relations use the per-backend LOCAL buffer pool, a distinct code
+-- path from shared buffers.  A temp relation larger than temp_buffers forces
+-- local-buffer reuse; all rows must remain correct after eviction and reload
+-- from the temp relation's storage.
+--
+-- The working-set size is derived from temp_buffers in bytes, so it exceeds the
+-- local pool at any BLCKSZ (a fixed row count could fall under temp_buffers on
+-- a large BLCKSZ and silently stop exercising local eviction). Local buffers
+-- have no BufferAccessStrategy ring, so a plain scan drives the reuse and
+-- pg_buffercache exposes only shared buffers, not local ones, so this test
+-- observes correctness through contents alone: if local eviction/reload were
+-- wrong, some row would read back corrupt.
+CREATE TEMP TABLE le_local (id int, payload text) WITH (autovacuum_enabled = off);
+-- Fill to several times temp_buffers (in bytes) so the local pool must reuse
+-- buffers both while inserting and while scanning.
+DO $$
+DECLARE
+  target bigint;
+  rnum     int := 0;
+BEGIN
+  SELECT setting::bigint * current_setting('block_size')::bigint * 3 INTO target
+    FROM pg_settings WHERE name = 'temp_buffers';
+  WHILE pg_relation_size('le_local'::regclass) < target LOOP
+    INSERT INTO le_local SELECT g, md5(g::text) FROM generate_series(rnum + 1, rnum + 10000) g;
+    rnum := rnum + 10000;
+  END LOOP;
+END $$;
+-- Repeated full scans re-read every block through the small local pool.
+SELECT count(*) > 0 AS has_rows FROM le_local;
+ has_rows 
+----------
+ t
+(1 row)
+
+SELECT count(*) AS corrupt_rows FROM le_local WHERE payload <> md5(id::text);
+ corrupt_rows 
+--------------
+            0
+(1 row)
+
+DROP TABLE le_local;
diff --git a/src/test/modules/test_bufmgr/expected/test_bufmgr_pinned_safety.out b/src/test/modules/test_bufmgr/expected/test_bufmgr_pinned_safety.out
new file mode 100644
index 00000000000..797aa527ee5
--- /dev/null
+++ b/src/test/modules/test_bufmgr/expected/test_bufmgr_pinned_safety.out
@@ -0,0 +1,47 @@
+Parsed test spec with 2 sessions
+
+starting permutation: p_pin f_flood p_check p_verify p_commit
+step p_pin: 
+  BEGIN;
+  DECLARE cur CURSOR FOR SELECT id, payload FROM pinned;
+  FETCH NEXT FROM cur;
+
+id|payload                         
+--+--------------------------------
+ 1|c4ca4238a0b923820dcc509a6f75849b
+(1 row)
+
+step f_flood: 
+  SELECT pg_prewarm('flood') > 0 AS flooded;
+
+flooded
+-------
+t      
+(1 row)
+
+step p_check: 
+  SELECT
+    (SELECT count(*) > 0 FROM pg_buffercache
+       WHERE relfilenode = pg_relation_filenode('pinned'::regclass)
+         AND relforknumber = 0 AND relblocknumber = 0) AS pinned_resident,
+    (SELECT bool_or(pinning_backends >= 1) FROM pg_buffercache
+       WHERE relfilenode = pg_relation_filenode('pinned'::regclass)
+         AND relforknumber = 0 AND relblocknumber = 0) AS pinned_pinned,
+    (SELECT count(*) = 0 FROM pg_buffercache
+       WHERE relfilenode = pg_relation_filenode('probe'::regclass)
+         AND relforknumber = 0 AND relblocknumber = 0) AS probe_evicted;
+
+pinned_resident|pinned_pinned|probe_evicted
+---------------+-------------+-------------
+t              |t            |t            
+(1 row)
+
+step p_verify: 
+  SELECT (payload = md5(id::text)) AS content_ok FROM pinned WHERE id = 1;
+
+content_ok
+----------
+t         
+(1 row)
+
+step p_commit: COMMIT;
diff --git a/src/test/modules/test_bufmgr/expected/test_bufmgr_scan_no_retain.out b/src/test/modules/test_bufmgr/expected/test_bufmgr_scan_no_retain.out
new file mode 100644
index 00000000000..28ec682308e
--- /dev/null
+++ b/src/test/modules/test_bufmgr/expected/test_bufmgr_scan_no_retain.out
@@ -0,0 +1,56 @@
+-- Test - a page read once and never again does not indefinitely retain its
+-- buffer under sustained allocation pressure.
+--
+-- This is the universally-true direction of scan resistance: one-touch pages
+-- are reclaimable, so pressure cannot be indefinitely denied.
+-- Every correct buffer manager satisfies this, regardless of replacement policy, and
+-- the test asserts only an end-state (the one-touch relation becomes fully
+-- non-resident under enough pressure), never which buffer is chosen.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+CREATE EXTENSION IF NOT EXISTS pg_prewarm;
+DROP TABLE IF EXISTS sr_oneshot, sr_flood;
+CREATE TABLE sr_oneshot (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO sr_oneshot SELECT g, md5(g::text) FROM generate_series(1, 2000) g;
+-- Touch every sr_oneshot block exactly once (prewarm), then never again.
+SELECT pg_prewarm('sr_oneshot') > 0 AS oneshot_resident;
+ oneshot_resident 
+------------------
+ t
+(1 row)
+
+-- Self-calibrating flood until the one-touch relation is fully
+-- reclaimed (no resident blocks left), bounded by a generous cap.
+DO $$
+DECLARE
+  now_resident bigint;
+  first_blk    bigint;
+  last_blk     bigint;
+  rounds       int := 0;
+BEGIN
+  CREATE TABLE sr_flood (id int, payload text) WITH (autovacuum_enabled = off);
+  LOOP
+    rounds := rounds + 1;
+    first_blk := pg_relation_size('sr_flood') / current_setting('block_size')::bigint;
+    INSERT INTO sr_flood SELECT g, md5(g::text) FROM generate_series(1, 10000) g;
+    last_blk := pg_relation_size('sr_flood') / current_setting('block_size')::bigint - 1;
+    PERFORM pg_prewarm('sr_flood', 'buffer', 'main', first_blk, last_blk);
+    SELECT count(*) INTO now_resident FROM pg_buffercache
+     WHERE relfilenode = pg_relation_filenode('sr_oneshot'::regclass)
+       AND relforknumber = 0;
+    EXIT WHEN now_resident = 0;
+    IF rounds > 200 THEN
+      RAISE EXCEPTION 'sr_oneshot still has % resident blocks after % flood rounds',
+        now_resident, rounds;
+    END IF;
+  END LOOP;
+END $$;
+-- The one-touch relation retained no buffers under pressure.
+SELECT count(*) AS still_resident FROM pg_buffercache
+ WHERE relfilenode = pg_relation_filenode('sr_oneshot'::regclass)
+   AND relforknumber = 0;
+ still_resident 
+----------------
+              0
+(1 row)
+
+DROP TABLE sr_oneshot, sr_flood;
diff --git a/src/test/modules/test_bufmgr/expected/test_bufmgr_truncate_stale.out b/src/test/modules/test_bufmgr/expected/test_bufmgr_truncate_stale.out
new file mode 100644
index 00000000000..70c9ae25539
--- /dev/null
+++ b/src/test/modules/test_bufmgr/expected/test_bufmgr_truncate_stale.out
@@ -0,0 +1,28 @@
+-- Test - TRUNCATE must not leave stale cached pages that later become visible.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+DROP TABLE IF EXISTS tr_rel;
+CREATE TABLE tr_rel (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO tr_rel SELECT g, 'v1-' || md5(g::text) FROM generate_series(1, 5000) g;
+SELECT count(*) FROM tr_rel;   -- cache the v1 pages
+ count 
+-------
+  5000
+(1 row)
+
+TRUNCATE tr_rel;
+-- Repopulate with DIFFERENT (v2) contents, extending the relation again.
+INSERT INTO tr_rel SELECT g, 'v2-' || md5(g::text) FROM generate_series(1, 5000) g;
+-- No row may carry a v1 payload, and every row must be its correct v2 value.
+SELECT count(*) AS stale_v1_rows FROM tr_rel WHERE payload LIKE 'v1-%';
+ stale_v1_rows 
+---------------
+             0
+(1 row)
+
+SELECT count(*) AS wrong_v2_rows FROM tr_rel WHERE payload <> 'v2-' || md5(id::text);
+ wrong_v2_rows 
+---------------
+             0
+(1 row)
+
+DROP TABLE tr_rel;
diff --git a/src/test/modules/test_bufmgr/meson.build b/src/test/modules/test_bufmgr/meson.build
new file mode 100644
index 00000000000..e8e5d6093e3
--- /dev/null
+++ b/src/test/modules/test_bufmgr/meson.build
@@ -0,0 +1,28 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+tests += {
+  'name': 'test_bufmgr',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'test_bufmgr_eviction_reload',
+      'test_bufmgr_dirty_persistence',
+      'test_bufmgr_scan_no_retain',
+      'test_bufmgr_truncate_stale',
+      'test_bufmgr_drop_recreate_stale',
+      'test_bufmgr_local_eviction',
+      'test_bufmgr_churn',
+    ],
+    'regress_args': ['--temp-config', files('test_bufmgr_small.conf')],
+    # the small pool these tests need cannot be given to a running server
+    'runningcheck': false,
+  },
+  'isolation': {
+    'specs': [
+      'test_bufmgr_pinned_safety',
+    ],
+    'regress_args': ['--temp-config', files('test_bufmgr_small.conf')],
+    'runningcheck': false,
+  },
+}
diff --git a/src/test/modules/test_bufmgr/specs/test_bufmgr_pinned_safety.spec b/src/test/modules/test_bufmgr/specs/test_bufmgr_pinned_safety.spec
new file mode 100644
index 00000000000..f4375c839b5
--- /dev/null
+++ b/src/test/modules/test_bufmgr/specs/test_bufmgr_pinned_safety.spec
@@ -0,0 +1,99 @@
+# Test - pinned-buffer safety under eviction pressure.
+#
+# Property (must hold for ANY correct buffer manager): a pinned buffer is never
+# selected as an eviction victim. While one backend holds a pin on a page,
+# another backend can drive allocation pressure far exceeding the whole shared
+# buffer pool, and the pinned page must remain resident and its contents valid.
+#
+# The pin is held across statements using an existing PostgreSQL facility,
+# a suspended cursor. A plain transaction does not keep heap-page pins between
+# statements (each scan drops its pins when it finishes), but a cursor that has
+# fetched into its scan keeps the current page pinned until it advances or
+# closes.
+#
+# A control "probe" block, resident but UNPINNED, guards against a vacuous
+# pass: the flood must actually exceed the pool, which we prove by asserting the
+# probe block is evicted while the pinned block survives. If the flood were too
+# small, the probe would remain resident and the test would fail loudly instead
+# of passing for the wrong reason.
+
+setup
+{
+  CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+  CREATE EXTENSION IF NOT EXISTS pg_prewarm;
+  CREATE TABLE pinned (id int, payload text) WITH (autovacuum_enabled = off);
+  INSERT INTO pinned SELECT g, md5(g::text) FROM generate_series(1, 500) g;
+  CREATE TABLE probe (id int, payload text) WITH (autovacuum_enabled = off);
+  INSERT INTO probe SELECT g, md5(g::text) FROM generate_series(1, 500) g;
+  CREATE TABLE flood (id int, payload text) WITH (autovacuum_enabled = off);
+}
+setup
+{
+  DO $$
+  DECLARE
+    target bigint;
+    rnum     int := 0;
+  BEGIN
+    SELECT setting::bigint * current_setting('block_size')::bigint * 2 INTO target
+      FROM pg_settings WHERE name = 'shared_buffers';
+    WHILE pg_relation_size('flood'::regclass) < target LOOP
+      INSERT INTO flood SELECT g, md5(g::text) FROM generate_series(rnum + 1, rnum + 10000) g;
+      rnum := rnum + 10000;
+    END LOOP;
+  END $$;
+}
+setup
+{
+  -- Make the unpinned control probe resident right before the test runs.
+  -- (PERFORM, not SELECT: the raw prewarmed-block count varies with BLCKSZ.)
+  DO $$ BEGIN PERFORM pg_prewarm('probe'); END $$;
+}
+
+teardown
+{
+  DROP TABLE IF EXISTS pinned;
+  DROP TABLE IF EXISTS probe;
+  DROP TABLE IF EXISTS flood;
+}
+
+# Holds a pin on pinned's block 0 via a suspended cursor.
+session pinholder
+step p_pin
+{
+  BEGIN;
+  DECLARE cur CURSOR FOR SELECT id, payload FROM pinned;
+  FETCH NEXT FROM cur;
+}
+# The pinned block must still be resident AND still show a live pin held by us,
+# while the unpinned control probe block must have been evicted by the flood.
+step p_check
+{
+  SELECT
+    (SELECT count(*) > 0 FROM pg_buffercache
+       WHERE relfilenode = pg_relation_filenode('pinned'::regclass)
+         AND relforknumber = 0 AND relblocknumber = 0) AS pinned_resident,
+    (SELECT bool_or(pinning_backends >= 1) FROM pg_buffercache
+       WHERE relfilenode = pg_relation_filenode('pinned'::regclass)
+         AND relforknumber = 0 AND relblocknumber = 0) AS pinned_pinned,
+    (SELECT count(*) = 0 FROM pg_buffercache
+       WHERE relfilenode = pg_relation_filenode('probe'::regclass)
+         AND relforknumber = 0 AND relblocknumber = 0) AS probe_evicted;
+}
+# The contents of the pinned page are intact and unchanged (id=1 lives on
+# block 0): read it back and confirm its payload still matches.
+step p_verify
+{
+  SELECT (payload = md5(id::text)) AS content_ok FROM pinned WHERE id = 1;
+}
+step p_commit { COMMIT; }
+
+# Drives allocation pressure far larger than the whole pool, ring-free.
+session flooder
+step f_flood
+{
+  SELECT pg_prewarm('flood') > 0 AS flooded;
+}
+
+# Pin, flood past the pool, then confirm the pinned page survived intact while
+# the unpinned probe did not.
+permutation p_pin f_flood p_check p_verify p_commit
diff --git a/src/test/modules/test_bufmgr/sql/test_bufmgr_churn.sql b/src/test/modules/test_bufmgr/sql/test_bufmgr_churn.sql
new file mode 100644
index 00000000000..7cce10c33f8
--- /dev/null
+++ b/src/test/modules/test_bufmgr/sql/test_bufmgr_churn.sql
@@ -0,0 +1,52 @@
+-- Test - repeated eviction and reload cycles.
+--
+-- Several relations whose combined working set exceeds the pool, cycled many
+-- times so most blocks are reloaded from storage on every pass. Contents must
+-- remain correct after many replacement/reload cycles.  The test never asserts
+-- which buffers are selected as victims.
+--
+-- Sizes are derived from shared_buffers in bytes (not a fixed row count), so
+-- the combined working set exceeds the pool at any BLCKSZ. Cycling is done
+-- with ring-free pg_prewarm so it drives real pool-wide reuse.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+CREATE EXTENSION IF NOT EXISTS pg_prewarm;
+DROP TABLE IF EXISTS ch_a, ch_b, ch_c;
+CREATE TABLE ch_a (id int, payload text) WITH (autovacuum_enabled = off);
+CREATE TABLE ch_b (id int, payload text) WITH (autovacuum_enabled = off);
+CREATE TABLE ch_c (id int, payload text) WITH (autovacuum_enabled = off);
+-- Fill each relation to roughly one whole pool in bytes, so the three combined
+-- are ~3x shared_buffers and no single pass can stay resident.
+DO $$
+DECLARE
+  target   bigint;
+  tbl      text;
+  rnum       int;
+BEGIN
+  SELECT setting::bigint * current_setting('block_size')::bigint INTO target
+    FROM pg_settings WHERE name = 'shared_buffers';
+  FOREACH tbl IN ARRAY ARRAY['ch_a','ch_b','ch_c'] LOOP
+    rnum := 0;
+    WHILE pg_relation_size(tbl::regclass) < target LOOP
+      EXECUTE format(
+        'INSERT INTO %I SELECT g, %L || md5(g::text) FROM generate_series(%s, %s) g',
+        tbl, right(tbl, 1) || '-', rnum + 1, rnum + 10000);
+      rnum := rnum + 10000;
+    END LOOP;
+  END LOOP;
+END $$;
+-- Cycle through all three many times; each prewarm reloads a whole pool's worth
+-- of blocks, evicting the others.
+DO $$
+BEGIN
+  FOR i IN 1..15 LOOP
+    PERFORM pg_prewarm('ch_a');
+    PERFORM pg_prewarm('ch_b');
+    PERFORM pg_prewarm('ch_c');
+  END LOOP;
+END $$;
+-- After all the churn, every row in every relation must still be correct.
+SELECT count(*) AS bad_a FROM ch_a WHERE payload <> 'a-' || md5(id::text);
+SELECT count(*) AS bad_b FROM ch_b WHERE payload <> 'b-' || md5(id::text);
+SELECT count(*) AS bad_c FROM ch_c WHERE payload <> 'c-' || md5(id::text);
+
+DROP TABLE ch_a, ch_b, ch_c;
diff --git a/src/test/modules/test_bufmgr/sql/test_bufmgr_dirty_persistence.sql b/src/test/modules/test_bufmgr/sql/test_bufmgr_dirty_persistence.sql
new file mode 100644
index 00000000000..b937cc53e96
--- /dev/null
+++ b/src/test/modules/test_bufmgr/sql/test_bufmgr_dirty_persistence.sql
@@ -0,0 +1,56 @@
+-- Test - dirty-page persistence under eviction pressure.
+--
+-- Test that when a modified (dirty) page is evicted under pressure, the buffer
+-- manager must have written its contents out, so a subsequent reload returns
+-- the modified contents, not the pre-modification ones.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+CREATE EXTENSION IF NOT EXISTS pg_prewarm;
+DROP TABLE IF EXISTS dp_dirt, dp_flood;
+CREATE TABLE dp_dirt (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO dp_dirt SELECT g, 'orig-' || md5(g::text) FROM generate_series(1, 20000) g;
+-- Modify every row: payload becomes a new deterministic value.  All heap pages
+-- holding live tuples are now dirty and must be flushed on eviction.
+UPDATE dp_dirt SET payload = 'mod-' || md5(id::text);
+-- Make the dirty relation fully resident (prewarm hits keep the dirty buffers
+-- dirty; it never cleans them), then record the resident peak.
+SELECT pg_prewarm('dp_dirt') > 0 AS dirt_prewarmed;
+
+-- Self-calibrating flood until at least one dirty dp_dirt block has
+-- been evicted (its resident count drops from the peak).  Since every dp_dirt
+-- page is dirty, evicting any of them exercises the write-on-evict path.
+DO $$
+DECLARE
+  peak         bigint;
+  now_resident bigint;
+  first_blk    bigint;
+  last_blk     bigint;
+  rounds       int := 0;
+BEGIN
+  SELECT count(*) INTO peak FROM pg_buffercache
+   WHERE relfilenode = pg_relation_filenode('dp_dirt'::regclass)
+     AND relforknumber = 0;
+
+  CREATE TABLE dp_flood (id int, payload text) WITH (autovacuum_enabled = off);
+  LOOP
+    rounds := rounds + 1;
+    first_blk := pg_relation_size('dp_flood') / current_setting('block_size')::bigint;
+    INSERT INTO dp_flood SELECT g, md5(g::text) FROM generate_series(1, 10000) g;
+    last_blk := pg_relation_size('dp_flood') / current_setting('block_size')::bigint - 1;
+    PERFORM pg_prewarm('dp_flood', 'buffer', 'main', first_blk, last_blk);
+    SELECT count(*) INTO now_resident FROM pg_buffercache
+     WHERE relfilenode = pg_relation_filenode('dp_dirt'::regclass)
+       AND relforknumber = 0;
+    EXIT WHEN now_resident < peak;
+    IF rounds > 200 THEN
+      RAISE EXCEPTION 'no dirty dp_dirt block evicted after % flood rounds (peak=%, now=%)',
+        rounds, peak, now_resident;
+    END IF;
+  END LOOP;
+END $$;
+-- Reload correctness: every row must carry its MODIFIED payload.  A row still
+-- showing an 'orig-' payload would mean an evicted dirty page was not written
+-- out (or was written stale) before reuse.
+SELECT count(*) AS unpersisted_rows FROM dp_dirt
+ WHERE payload <> 'mod-' || md5(id::text);
+
+DROP TABLE dp_dirt, dp_flood;
diff --git a/src/test/modules/test_bufmgr/sql/test_bufmgr_drop_recreate_stale.sql b/src/test/modules/test_bufmgr/sql/test_bufmgr_drop_recreate_stale.sql
new file mode 100644
index 00000000000..4dc05c19dce
--- /dev/null
+++ b/src/test/modules/test_bufmgr/sql/test_bufmgr_drop_recreate_stale.sql
@@ -0,0 +1,21 @@
+-- Test - dropping a relation must not let its cached pages resurface through
+-- a later relation that reuses the same relfilenode / blocks.
+
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+DROP TABLE IF EXISTS dr_old, dr_new, dr_flood;
+CREATE TABLE dr_old (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO dr_old SELECT g, 'old-' || md5(g::text) FROM generate_series(1, 5000) g;
+SELECT count(*) FROM dr_old;   -- cache dr_old's pages
+DROP TABLE dr_old;
+-- New relation plus flood pressure; the new relation may land on dr_old's
+-- recycled relfilenode/blocks.
+CREATE TABLE dr_new (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO dr_new SELECT g, 'new-' || md5(g::text) FROM generate_series(1, 5000) g;
+CREATE TABLE dr_flood (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO dr_flood SELECT g, md5(g::text) FROM generate_series(1, 30000) g;
+SELECT count(*) FROM dr_flood;
+-- No dr_new row may carry a dropped-relation payload; all must be correct.
+SELECT count(*) AS stale_old_rows FROM dr_new WHERE payload LIKE 'old-%';
+SELECT count(*) AS wrong_new_rows FROM dr_new WHERE payload <> 'new-' || md5(id::text);
+
+DROP TABLE dr_new, dr_flood;
diff --git a/src/test/modules/test_bufmgr/sql/test_bufmgr_eviction_reload.sql b/src/test/modules/test_bufmgr/sql/test_bufmgr_eviction_reload.sql
new file mode 100644
index 00000000000..0a06f1f06af
--- /dev/null
+++ b/src/test/modules/test_bufmgr/sql/test_bufmgr_eviction_reload.sql
@@ -0,0 +1,63 @@
+-- Test - eviction and reload correctness:
+--
+-- Property (must hold for ANY correct buffer manager, regardless of its
+-- replacement policy): under allocation pressure whose working set exceeds
+-- shared_buffers, at least one previously-resident, unpinned page is evicted;
+-- reloading any evicted page returns its original contents; and the manager
+-- makes progress (no "no unpinned buffers available" error).
+--
+-- Observed only through pg_buffercache residency (a row present for a relation
+-- block == that block is resident) and through the page contents themselves,
+-- never through usagecount or any replacement-policy internal. The test never
+-- predicts which page is chosen as the victim.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+CREATE EXTENSION IF NOT EXISTS pg_prewarm;
+DROP TABLE IF EXISTS er_victim, er_flood;
+-- Uniquely identifiable pages: each row's payload is a deterministic function
+-- of its id, so any block read back can be verified without assuming how many
+-- rows fall on a page or which page a given row lands on.
+CREATE TABLE er_victim (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO er_victim SELECT g, md5(g::text) FROM generate_series(1, 20000) g;
+SELECT pg_prewarm('er_victim') > 0 AS victim_prewarmed;
+
+-- Self-calibrating flood: grow a throwaway relation and prewarm each newly
+-- added block range until at least one er_victim block that was
+-- resident has been evicted i.e. er_victim's resident-block count has
+-- strictly dropped from its post-prewarm peak. This asserts an observed
+-- end-state ("reclaim happened") rather than a hand-tuned flood size, and is
+-- bounded by a generous cap so a real failure still terminates the test.
+DO $$
+DECLARE
+  peak         bigint;
+  now_resident bigint;
+  first_blk    bigint;
+  last_blk     bigint;
+  rounds       int := 0;
+BEGIN
+  SELECT count(*) INTO peak FROM pg_buffercache
+   WHERE relfilenode = pg_relation_filenode('er_victim'::regclass)
+     AND relforknumber = 0;
+
+  CREATE TABLE er_flood (id int, payload text) WITH (autovacuum_enabled = off);
+  LOOP
+    rounds := rounds + 1;
+    first_blk := pg_relation_size('er_flood') / current_setting('block_size')::bigint;
+    INSERT INTO er_flood SELECT g, md5(g::text) FROM generate_series(1, 10000) g;
+    last_blk := pg_relation_size('er_flood') / current_setting('block_size')::bigint - 1;
+    PERFORM pg_prewarm('er_flood', 'buffer', 'main', first_blk, last_blk);
+    SELECT count(*) INTO now_resident FROM pg_buffercache
+     WHERE relfilenode = pg_relation_filenode('er_victim'::regclass)
+       AND relforknumber = 0;
+    EXIT WHEN now_resident < peak;    -- a victim block was reclaimed
+    IF rounds > 200 THEN
+      RAISE EXCEPTION 'no er_victim block evicted after % flood rounds (peak=%, now=%)',
+        rounds, peak, now_resident;
+    END IF;
+  END LOOP;
+END $$;
+-- Reload correctness: this full scan re-reads every block from storage,
+-- including the evicted ones, and every row must still carry its original
+-- payload.  A nonzero count would mean a reloaded page returned wrong contents.
+SELECT count(*) AS corrupt_rows FROM er_victim WHERE payload <> md5(id::text);
+
+DROP TABLE er_victim, er_flood;
diff --git a/src/test/modules/test_bufmgr/sql/test_bufmgr_local_eviction.sql b/src/test/modules/test_bufmgr/sql/test_bufmgr_local_eviction.sql
new file mode 100644
index 00000000000..ac209bc9972
--- /dev/null
+++ b/src/test/modules/test_bufmgr/sql/test_bufmgr_local_eviction.sql
@@ -0,0 +1,34 @@
+-- Test - local (temporary-table) buffer eviction correctness.
+--
+-- Temporary relations use the per-backend LOCAL buffer pool, a distinct code
+-- path from shared buffers.  A temp relation larger than temp_buffers forces
+-- local-buffer reuse; all rows must remain correct after eviction and reload
+-- from the temp relation's storage.
+--
+-- The working-set size is derived from temp_buffers in bytes, so it exceeds the
+-- local pool at any BLCKSZ (a fixed row count could fall under temp_buffers on
+-- a large BLCKSZ and silently stop exercising local eviction). Local buffers
+-- have no BufferAccessStrategy ring, so a plain scan drives the reuse and
+-- pg_buffercache exposes only shared buffers, not local ones, so this test
+-- observes correctness through contents alone: if local eviction/reload were
+-- wrong, some row would read back corrupt.
+CREATE TEMP TABLE le_local (id int, payload text) WITH (autovacuum_enabled = off);
+-- Fill to several times temp_buffers (in bytes) so the local pool must reuse
+-- buffers both while inserting and while scanning.
+DO $$
+DECLARE
+  target bigint;
+  rnum     int := 0;
+BEGIN
+  SELECT setting::bigint * current_setting('block_size')::bigint * 3 INTO target
+    FROM pg_settings WHERE name = 'temp_buffers';
+  WHILE pg_relation_size('le_local'::regclass) < target LOOP
+    INSERT INTO le_local SELECT g, md5(g::text) FROM generate_series(rnum + 1, rnum + 10000) g;
+    rnum := rnum + 10000;
+  END LOOP;
+END $$;
+-- Repeated full scans re-read every block through the small local pool.
+SELECT count(*) > 0 AS has_rows FROM le_local;
+SELECT count(*) AS corrupt_rows FROM le_local WHERE payload <> md5(id::text);
+
+DROP TABLE le_local;
diff --git a/src/test/modules/test_bufmgr/sql/test_bufmgr_scan_no_retain.sql b/src/test/modules/test_bufmgr/sql/test_bufmgr_scan_no_retain.sql
new file mode 100644
index 00000000000..c914a73f1fe
--- /dev/null
+++ b/src/test/modules/test_bufmgr/sql/test_bufmgr_scan_no_retain.sql
@@ -0,0 +1,48 @@
+-- Test - a page read once and never again does not indefinitely retain its
+-- buffer under sustained allocation pressure.
+--
+-- This is the universally-true direction of scan resistance: one-touch pages
+-- are reclaimable, so pressure cannot be indefinitely denied.
+-- Every correct buffer manager satisfies this, regardless of replacement policy, and
+-- the test asserts only an end-state (the one-touch relation becomes fully
+-- non-resident under enough pressure), never which buffer is chosen.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+CREATE EXTENSION IF NOT EXISTS pg_prewarm;
+DROP TABLE IF EXISTS sr_oneshot, sr_flood;
+CREATE TABLE sr_oneshot (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO sr_oneshot SELECT g, md5(g::text) FROM generate_series(1, 2000) g;
+-- Touch every sr_oneshot block exactly once (prewarm), then never again.
+SELECT pg_prewarm('sr_oneshot') > 0 AS oneshot_resident;
+
+-- Self-calibrating flood until the one-touch relation is fully
+-- reclaimed (no resident blocks left), bounded by a generous cap.
+DO $$
+DECLARE
+  now_resident bigint;
+  first_blk    bigint;
+  last_blk     bigint;
+  rounds       int := 0;
+BEGIN
+  CREATE TABLE sr_flood (id int, payload text) WITH (autovacuum_enabled = off);
+  LOOP
+    rounds := rounds + 1;
+    first_blk := pg_relation_size('sr_flood') / current_setting('block_size')::bigint;
+    INSERT INTO sr_flood SELECT g, md5(g::text) FROM generate_series(1, 10000) g;
+    last_blk := pg_relation_size('sr_flood') / current_setting('block_size')::bigint - 1;
+    PERFORM pg_prewarm('sr_flood', 'buffer', 'main', first_blk, last_blk);
+    SELECT count(*) INTO now_resident FROM pg_buffercache
+     WHERE relfilenode = pg_relation_filenode('sr_oneshot'::regclass)
+       AND relforknumber = 0;
+    EXIT WHEN now_resident = 0;
+    IF rounds > 200 THEN
+      RAISE EXCEPTION 'sr_oneshot still has % resident blocks after % flood rounds',
+        now_resident, rounds;
+    END IF;
+  END LOOP;
+END $$;
+-- The one-touch relation retained no buffers under pressure.
+SELECT count(*) AS still_resident FROM pg_buffercache
+ WHERE relfilenode = pg_relation_filenode('sr_oneshot'::regclass)
+   AND relforknumber = 0;
+
+DROP TABLE sr_oneshot, sr_flood;
diff --git a/src/test/modules/test_bufmgr/sql/test_bufmgr_truncate_stale.sql b/src/test/modules/test_bufmgr/sql/test_bufmgr_truncate_stale.sql
new file mode 100644
index 00000000000..5609246fbbc
--- /dev/null
+++ b/src/test/modules/test_bufmgr/sql/test_bufmgr_truncate_stale.sql
@@ -0,0 +1,14 @@
+-- Test - TRUNCATE must not leave stale cached pages that later become visible.
+CREATE EXTENSION IF NOT EXISTS pg_buffercache;
+DROP TABLE IF EXISTS tr_rel;
+CREATE TABLE tr_rel (id int, payload text) WITH (autovacuum_enabled = off);
+INSERT INTO tr_rel SELECT g, 'v1-' || md5(g::text) FROM generate_series(1, 5000) g;
+SELECT count(*) FROM tr_rel;   -- cache the v1 pages
+TRUNCATE tr_rel;
+-- Repopulate with DIFFERENT (v2) contents, extending the relation again.
+INSERT INTO tr_rel SELECT g, 'v2-' || md5(g::text) FROM generate_series(1, 5000) g;
+-- No row may carry a v1 payload, and every row must be its correct v2 value.
+SELECT count(*) AS stale_v1_rows FROM tr_rel WHERE payload LIKE 'v1-%';
+SELECT count(*) AS wrong_v2_rows FROM tr_rel WHERE payload <> 'v2-' || md5(id::text);
+
+DROP TABLE tr_rel;
diff --git a/src/test/modules/test_bufmgr/test_bufmgr_small.conf b/src/test/modules/test_bufmgr/test_bufmgr_small.conf
new file mode 100644
index 00000000000..9bbc2769d33
--- /dev/null
+++ b/src/test/modules/test_bufmgr/test_bufmgr_small.conf
@@ -0,0 +1,6 @@
+shared_buffers = 8MB
+temp_buffers = 100
+bgwriter_lru_maxpages = 0
+autovacuum = off
+checkpoint_timeout = 1h
+client_min_messages = warning
-- 
2.50.1 (Apple Git-155)

