From f73b61cbf44e4528c94b7b3b3994d60a16d18c6c Mon Sep 17 00:00:00 2001 From: Dinesh Salve 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)